1package repository
2
3import (
4 "io"
5 "math/rand"
6 "os"
7 "testing"
8 "time"
9
10 "github.com/ProtonMail/go-crypto/openpgp"
11 "github.com/stretchr/testify/require"
12
13 "github.com/git-bug/git-bug/util/lamport"
14)
15
16type RepoCreator func(t testing.TB, bare bool) TestedRepo
17
18// Test suite for a Repo implementation
19func RepoTest(t *testing.T, creator RepoCreator) {
20 for bare, name := range map[bool]string{
21 false: "Plain",
22 true: "Bare",
23 } {
24 t.Run(name, func(t *testing.T) {
25 repo := creator(t, bare)
26
27 t.Run("Data", func(t *testing.T) {
28 RepoDataTest(t, repo)
29 RepoDataSignatureTest(t, repo)
30 })
31
32 t.Run("Browse", func(t *testing.T) {
33 RepoBrowseTest(t, repo)
34 })
35
36 t.Run("Config", func(t *testing.T) {
37 RepoConfigTest(t, repo)
38 })
39
40 t.Run("Storage", func(t *testing.T) {
41 RepoStorageTest(t, repo)
42 })
43
44 t.Run("Index", func(t *testing.T) {
45 RepoIndexTest(t, repo)
46 })
47
48 t.Run("Clocks", func(t *testing.T) {
49 RepoClockTest(t, repo)
50 })
51 })
52 }
53}
54
55// helper to test a RepoConfig
56func RepoConfigTest(t *testing.T, repo RepoConfig) {
57 testConfig(t, repo.LocalConfig())
58}
59
60func RepoStorageTest(t *testing.T, repo RepoStorage) {
61 storage := repo.LocalStorage()
62
63 err := storage.MkdirAll("foo/bar", 0755)
64 require.NoError(t, err)
65
66 f, err := storage.Create("foo/bar/foofoo")
67 require.NoError(t, err)
68
69 _, err = f.Write([]byte("hello"))
70 require.NoError(t, err)
71
72 err = f.Close()
73 require.NoError(t, err)
74
75 // remove all
76 err = storage.RemoveAll(".")
77 require.NoError(t, err)
78
79 fi, err := storage.ReadDir(".")
80 // a real FS would remove the root directory with RemoveAll and subsequent call would fail
81 // a memory FS would still have a virtual root and subsequent call would succeed
82 // not ideal, but will do for now
83 if err == nil {
84 require.Empty(t, fi)
85 } else {
86 require.True(t, os.IsNotExist(err))
87 }
88}
89
90func randomHash() Hash {
91 var letterRunes = "abcdef0123456789"
92 b := make([]byte, idLengthSHA256)
93 for i := range b {
94 b[i] = letterRunes[rand.Intn(len(letterRunes))]
95 }
96 return Hash(b)
97}
98
99// helper to test a RepoData
100func RepoDataTest(t *testing.T, repo RepoData) {
101 // Blob
102
103 data := randomData()
104
105 blobHash1, err := repo.StoreData(data)
106 require.NoError(t, err)
107 require.True(t, blobHash1.IsValid())
108
109 blob1Read, err := repo.ReadData(blobHash1)
110 require.NoError(t, err)
111 require.Equal(t, data, blob1Read)
112
113 _, err = repo.ReadData(randomHash())
114 require.ErrorIs(t, err, ErrNotFound)
115
116 // Tree
117
118 blobHash2, err := repo.StoreData(randomData())
119 require.NoError(t, err)
120 blobHash3, err := repo.StoreData(randomData())
121 require.NoError(t, err)
122
123 tree1 := []TreeEntry{
124 {
125 ObjectType: Blob,
126 Hash: blobHash1,
127 Name: "blob1",
128 },
129 {
130 ObjectType: Blob,
131 Hash: blobHash2,
132 Name: "blob2",
133 },
134 }
135
136 treeHash1, err := repo.StoreTree(tree1)
137 require.NoError(t, err)
138 require.True(t, treeHash1.IsValid())
139
140 tree1Read, err := repo.ReadTree(treeHash1)
141 require.NoError(t, err)
142 require.ElementsMatch(t, tree1, tree1Read)
143
144 tree2 := []TreeEntry{
145 {
146 ObjectType: Tree,
147 Hash: treeHash1,
148 Name: "tree1",
149 },
150 {
151 ObjectType: Blob,
152 Hash: blobHash3,
153 Name: "blob3",
154 },
155 }
156
157 treeHash2, err := repo.StoreTree(tree2)
158 require.NoError(t, err)
159 require.True(t, treeHash2.IsValid())
160
161 tree2Read, err := repo.ReadTree(treeHash2)
162 require.NoError(t, err)
163 require.ElementsMatch(t, tree2, tree2Read)
164
165 _, err = repo.ReadTree(randomHash())
166 require.ErrorIs(t, err, ErrNotFound)
167
168 // Commit
169
170 commit1, err := repo.StoreCommit(treeHash1)
171 require.NoError(t, err)
172 require.True(t, commit1.IsValid())
173
174 // commit with a parent
175 commit2, err := repo.StoreCommit(treeHash2, commit1)
176 require.NoError(t, err)
177 require.True(t, commit2.IsValid())
178
179 // ReadTree should accept tree and commit hashes
180 tree1read, err := repo.ReadTree(commit1)
181 require.NoError(t, err)
182 require.Equal(t, tree1read, tree1)
183
184 c2, err := repo.ReadCommit(commit2)
185 require.NoError(t, err)
186 c2expected := Commit{Hash: commit2, Parents: []Hash{commit1}, TreeHash: treeHash2}
187 require.Equal(t, c2expected, c2)
188
189 _, err = repo.ReadCommit(randomHash())
190 require.ErrorIs(t, err, ErrNotFound)
191
192 // Ref
193
194 exist1, err := repo.RefExist("refs/bugs/ref1")
195 require.NoError(t, err)
196 require.False(t, exist1)
197
198 err = repo.UpdateRef("refs/bugs/ref1", commit2)
199 require.NoError(t, err)
200
201 exist1, err = repo.RefExist("refs/bugs/ref1")
202 require.NoError(t, err)
203 require.True(t, exist1)
204
205 h, err := repo.ResolveRef("refs/bugs/ref1")
206 require.NoError(t, err)
207 require.Equal(t, commit2, h)
208
209 ls, err := repo.ListRefs("refs/bugs")
210 require.NoError(t, err)
211 require.ElementsMatch(t, []string{"refs/bugs/ref1"}, ls)
212
213 err = repo.CopyRef("refs/bugs/ref1", "refs/bugs/ref2")
214 require.NoError(t, err)
215
216 ls, err = repo.ListRefs("refs/bugs")
217 require.NoError(t, err)
218 require.ElementsMatch(t, []string{"refs/bugs/ref1", "refs/bugs/ref2"}, ls)
219
220 commits, err := repo.ListCommits("refs/bugs/ref2")
221 require.NoError(t, err)
222 require.Equal(t, []Hash{commit1, commit2}, commits)
223
224 _, err = repo.ResolveRef("/refs/bugs/refnotexist")
225 require.ErrorIs(t, err, ErrNotFound)
226
227 err = repo.CopyRef("/refs/bugs/refnotexist", "refs/foo")
228 require.ErrorIs(t, err, ErrNotFound)
229
230 // Cleanup
231
232 err = repo.RemoveRef("refs/bugs/ref1")
233 require.NoError(t, err)
234
235 // RemoveRef is idempotent
236 err = repo.RemoveRef("refs/bugs/ref1")
237 require.NoError(t, err)
238}
239
240func RepoDataSignatureTest(t *testing.T, repo RepoData) {
241 data := randomData()
242
243 blobHash, err := repo.StoreData(data)
244 require.NoError(t, err)
245
246 treeHash, err := repo.StoreTree([]TreeEntry{
247 {
248 ObjectType: Blob,
249 Hash: blobHash,
250 Name: "blob",
251 },
252 })
253 require.NoError(t, err)
254
255 pgpEntity1, err := openpgp.NewEntity("", "", "", nil)
256 require.NoError(t, err)
257 keyring1 := openpgp.EntityList{pgpEntity1}
258
259 pgpEntity2, err := openpgp.NewEntity("", "", "", nil)
260 require.NoError(t, err)
261 keyring2 := openpgp.EntityList{pgpEntity2}
262
263 commitHash1, err := repo.StoreSignedCommit(treeHash, pgpEntity1)
264 require.NoError(t, err)
265
266 commit1, err := repo.ReadCommit(commitHash1)
267 require.NoError(t, err)
268
269 _, err = openpgp.CheckDetachedSignature(keyring1, commit1.SignedData, commit1.Signature, nil)
270 require.NoError(t, err)
271
272 _, err = openpgp.CheckDetachedSignature(keyring2, commit1.SignedData, commit1.Signature, nil)
273 require.Error(t, err)
274
275 commitHash2, err := repo.StoreSignedCommit(treeHash, pgpEntity1, commitHash1)
276 require.NoError(t, err)
277
278 commit2, err := repo.ReadCommit(commitHash2)
279 require.NoError(t, err)
280
281 _, err = openpgp.CheckDetachedSignature(keyring1, commit2.SignedData, commit2.Signature, nil)
282 require.NoError(t, err)
283
284 _, err = openpgp.CheckDetachedSignature(keyring2, commit2.SignedData, commit2.Signature, nil)
285 require.Error(t, err)
286}
287
288func RepoIndexTest(t *testing.T, repo RepoIndex) {
289 idx, err := repo.GetIndex("a")
290 require.NoError(t, err)
291
292 // simple indexing
293 err = idx.IndexOne("id1", []string{"foo", "bar", "foobar barfoo"})
294 require.NoError(t, err)
295
296 // batched indexing
297 indexer, closer := idx.IndexBatch()
298 err = indexer("id2", []string{"hello", "foo bar"})
299 require.NoError(t, err)
300 err = indexer("id3", []string{"Hola", "Esta bien"})
301 require.NoError(t, err)
302 err = closer()
303 require.NoError(t, err)
304
305 // search
306 res, err := idx.Search([]string{"foobar"})
307 require.NoError(t, err)
308 require.ElementsMatch(t, []string{"id1"}, res)
309
310 res, err = idx.Search([]string{"foo"})
311 require.NoError(t, err)
312 require.ElementsMatch(t, []string{"id1", "id2"}, res)
313
314 // re-indexing an item replace previous versions
315 err = idx.IndexOne("id2", []string{"hello"})
316 require.NoError(t, err)
317
318 res, err = idx.Search([]string{"foo"})
319 require.NoError(t, err)
320 require.ElementsMatch(t, []string{"id1"}, res)
321
322 err = idx.Clear()
323 require.NoError(t, err)
324
325 res, err = idx.Search([]string{"foo"})
326 require.NoError(t, err)
327 require.Empty(t, res)
328}
329
330// helper to test a RepoClock
331func RepoClockTest(t *testing.T, repo RepoClock) {
332 allClocks, err := repo.AllClocks()
333 require.NoError(t, err)
334 require.Len(t, allClocks, 0)
335
336 clock, err := repo.GetOrCreateClock("foo")
337 require.NoError(t, err)
338 require.Equal(t, lamport.Time(1), clock.Time())
339
340 time, err := clock.Increment()
341 require.NoError(t, err)
342 require.Equal(t, lamport.Time(2), time)
343 require.Equal(t, lamport.Time(2), clock.Time())
344
345 clock2, err := repo.GetOrCreateClock("foo")
346 require.NoError(t, err)
347 require.Equal(t, lamport.Time(2), clock2.Time())
348
349 clock3, err := repo.GetOrCreateClock("bar")
350 require.NoError(t, err)
351 require.Equal(t, lamport.Time(1), clock3.Time())
352
353 allClocks, err = repo.AllClocks()
354 require.NoError(t, err)
355 require.Equal(t, map[string]lamport.Clock{
356 "foo": clock,
357 "bar": clock3,
358 }, allClocks)
359}
360
361func randomData() []byte {
362 var letterRunes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
363 b := make([]byte, 32)
364 for i := range b {
365 b[i] = letterRunes[rand.Intn(len(letterRunes))]
366 }
367 return b
368}
369
370// browsable is the interface required by RepoBrowseTest.
371type browsable interface {
372 RepoConfig
373 RepoData
374 RepoBrowse
375}
376
377// RepoBrowseTest exercises the RepoBrowse interface against any implementation.
378//
379// Commit graph (oldest → newest):
380//
381// c1 ── c2 ── c3 refs/heads/main (default)
382// └──────── refs/heads/feature
383// c1 ←── refs/tags/v1.0
384func RepoBrowseTest(t *testing.T, repo browsable) {
385 t.Helper()
386
387 require.NoError(t, repo.LocalConfig().StoreString("init.defaultBranch", "main"))
388
389 // ── build fixture ─────────────────────────────────────────────────────────
390
391 readmeV1 := []byte("# Hello\n")
392 readmeV3 := []byte("# Hello\n\n## Updated\n")
393 mainV1 := []byte("package main\n")
394 mainV2 := []byte("package main\n\n// updated\n")
395 libV1 := []byte("package lib\n")
396 utilV1 := []byte("package util\n")
397
398 hReadmeV1, err := repo.StoreData(readmeV1)
399 require.NoError(t, err)
400 hReadmeV3, err := repo.StoreData(readmeV3)
401 require.NoError(t, err)
402 hMainV1, err := repo.StoreData(mainV1)
403 require.NoError(t, err)
404 hMainV2, err := repo.StoreData(mainV2)
405 require.NoError(t, err)
406 hLibV1, err := repo.StoreData(libV1)
407 require.NoError(t, err)
408 hUtilV1, err := repo.StoreData(utilV1)
409 require.NoError(t, err)
410
411 srcTreeV1, err := repo.StoreTree([]TreeEntry{
412 {ObjectType: Blob, Hash: hLibV1, Name: "lib.go"},
413 })
414 require.NoError(t, err)
415 rootTreeV1, err := repo.StoreTree([]TreeEntry{
416 {ObjectType: Blob, Hash: hReadmeV1, Name: "README.md"},
417 {ObjectType: Blob, Hash: hMainV1, Name: "main.go"},
418 {ObjectType: Tree, Hash: srcTreeV1, Name: "src"},
419 })
420 require.NoError(t, err)
421
422 srcTreeV2, err := repo.StoreTree([]TreeEntry{
423 {ObjectType: Blob, Hash: hLibV1, Name: "lib.go"},
424 {ObjectType: Blob, Hash: hUtilV1, Name: "util.go"},
425 })
426 require.NoError(t, err)
427 rootTreeV2, err := repo.StoreTree([]TreeEntry{
428 {ObjectType: Blob, Hash: hReadmeV1, Name: "README.md"},
429 {ObjectType: Blob, Hash: hMainV2, Name: "main.go"},
430 {ObjectType: Tree, Hash: srcTreeV2, Name: "src"},
431 })
432 require.NoError(t, err)
433
434 rootTreeV3, err := repo.StoreTree([]TreeEntry{
435 {ObjectType: Blob, Hash: hReadmeV3, Name: "README.md"},
436 {ObjectType: Blob, Hash: hMainV2, Name: "main.go"},
437 {ObjectType: Tree, Hash: srcTreeV2, Name: "src"},
438 })
439 require.NoError(t, err)
440
441 c1, err := repo.StoreCommit(rootTreeV1)
442 require.NoError(t, err)
443 c2, err := repo.StoreCommit(rootTreeV2, c1)
444 require.NoError(t, err)
445 c3, err := repo.StoreCommit(rootTreeV3, c2)
446 require.NoError(t, err)
447
448 require.NoError(t, repo.UpdateRef("refs/heads/main", c3))
449 require.NoError(t, repo.UpdateRef("refs/heads/feature", c2))
450 require.NoError(t, repo.UpdateRef("refs/tags/v1.0", c1))
451
452 // ── Branches ──────────────────────────────────────────────────────────────
453
454 t.Run("Branches", func(t *testing.T) {
455 branches, err := repo.Branches()
456 require.NoError(t, err)
457 require.Len(t, branches, 2)
458
459 byName := make(map[string]BranchInfo)
460 for _, b := range branches {
461 byName[b.Name] = b
462 }
463
464 require.Equal(t, c3, byName["main"].Hash)
465 require.True(t, byName["main"].IsDefault)
466
467 require.Equal(t, c2, byName["feature"].Hash)
468 require.False(t, byName["feature"].IsDefault)
469 })
470
471 // ── Tags ──────────────────────────────────────────────────────────────────
472
473 t.Run("Tags", func(t *testing.T) {
474 tags, err := repo.Tags()
475 require.NoError(t, err)
476 require.Len(t, tags, 1)
477 require.Equal(t, "v1.0", tags[0].Name)
478 require.Equal(t, c1, tags[0].Hash)
479 })
480
481 // ── TreeAtPath ────────────────────────────────────────────────────────────
482
483 t.Run("TreeAtPath", func(t *testing.T) {
484 entries, err := repo.TreeAtPath("main", "")
485 require.NoError(t, err)
486 byName := make(map[string]TreeEntry)
487 for _, e := range entries {
488 byName[e.Name] = e
489 }
490 require.Equal(t, Blob, byName["README.md"].ObjectType)
491 require.Equal(t, Blob, byName["main.go"].ObjectType)
492 require.Equal(t, Tree, byName["src"].ObjectType)
493
494 // subdirectory
495 srcEntries, err := repo.TreeAtPath("main", "src")
496 require.NoError(t, err)
497 srcByName := make(map[string]TreeEntry)
498 for _, e := range srcEntries {
499 srcByName[e.Name] = e
500 }
501 require.Equal(t, Blob, srcByName["lib.go"].ObjectType)
502 require.Equal(t, Blob, srcByName["util.go"].ObjectType)
503
504 // v1.0 tag (at c1) predates util.go — src only has lib.go
505 v1Src, err := repo.TreeAtPath("v1.0", "src")
506 require.NoError(t, err)
507 require.Len(t, v1Src, 1)
508 require.Equal(t, "lib.go", v1Src[0].Name)
509
510 // unknown ref
511 _, err = repo.TreeAtPath("nonexistent-ref", "")
512 require.ErrorIs(t, err, ErrNotFound)
513
514 // path resolves to a blob, not a tree
515 _, err = repo.TreeAtPath("main", "README.md")
516 require.Error(t, err)
517 })
518
519 // ── BlobAtPath ────────────────────────────────────────────────────────────
520
521 t.Run("BlobAtPath", func(t *testing.T) {
522 rc, size, hash, err := repo.BlobAtPath("main", "README.md")
523 require.NoError(t, err)
524 defer rc.Close()
525 data, err := io.ReadAll(rc)
526 require.NoError(t, err)
527 require.Equal(t, readmeV3, data)
528 require.Equal(t, int64(len(readmeV3)), size)
529 require.NotEmpty(t, hash)
530
531 // feature branch still has readmeV1
532 rc2, _, _, err := repo.BlobAtPath("feature", "README.md")
533 require.NoError(t, err)
534 data2, err := io.ReadAll(rc2)
535 rc2.Close()
536 require.NoError(t, err)
537 require.Equal(t, readmeV1, data2)
538
539 // file in subdirectory
540 rc3, _, _, err := repo.BlobAtPath("main", "src/lib.go")
541 require.NoError(t, err)
542 data3, err := io.ReadAll(rc3)
543 rc3.Close()
544 require.NoError(t, err)
545 require.Equal(t, libV1, data3)
546
547 // path not found
548 _, _, _, err = repo.BlobAtPath("main", "nonexistent.go")
549 require.ErrorIs(t, err, ErrNotFound)
550
551 // hash is stable across calls for the same content
552 rc4, _, hash2, err := repo.BlobAtPath("main", "README.md")
553 require.NoError(t, err)
554 rc4.Close()
555 require.Equal(t, hash, hash2, "blob hash should be stable across calls")
556
557 // different content → different hash
558 rc5, _, hashLib, err := repo.BlobAtPath("main", "src/lib.go")
559 require.NoError(t, err)
560 rc5.Close()
561 require.NotEqual(t, hash, hashLib, "different files should have different hashes")
562 })
563
564 // ── CommitLog ─────────────────────────────────────────────────────────────
565
566 t.Run("CommitLog", func(t *testing.T) {
567 // all commits, newest first
568 commits, err := repo.CommitLog("main", "", 10, "", nil, nil)
569 require.NoError(t, err)
570 require.Len(t, commits, 3)
571 require.Equal(t, c3, commits[0].Hash)
572 require.Equal(t, c2, commits[1].Hash)
573 require.Equal(t, c1, commits[2].Hash)
574
575 // limit
576 limited, err := repo.CommitLog("main", "", 2, "", nil, nil)
577 require.NoError(t, err)
578 require.Len(t, limited, 2)
579 require.Equal(t, c3, limited[0].Hash)
580 require.Equal(t, c2, limited[1].Hash)
581
582 // after cursor (exclusive): start after c3 → get c2, c1
583 after, err := repo.CommitLog("main", "", 10, c3, nil, nil)
584 require.NoError(t, err)
585 require.Len(t, after, 2)
586 require.Equal(t, c2, after[0].Hash)
587 require.Equal(t, c1, after[1].Hash)
588
589 // feature branch only has c1, c2
590 featureLog, err := repo.CommitLog("feature", "", 10, "", nil, nil)
591 require.NoError(t, err)
592 require.Len(t, featureLog, 2)
593 require.Equal(t, c2, featureLog[0].Hash)
594
595 // path filtering: only commits that touched the given path
596 // README.md was created in c1 and updated in c3
597 readmeLog, err := repo.CommitLog("main", "README.md", 10, "", nil, nil)
598 require.NoError(t, err)
599 require.Len(t, readmeLog, 2)
600 require.Equal(t, c3, readmeLog[0].Hash)
601 require.Equal(t, c1, readmeLog[1].Hash)
602 })
603
604 t.Run("CommitLog/since-until", func(t *testing.T) {
605 // since = far future → no commits
606 future := time.Now().Add(24 * time.Hour)
607 none, err := repo.CommitLog("main", "", 10, "", &future, nil)
608 require.NoError(t, err)
609 require.Empty(t, none, "since=future should return no commits")
610
611 // until = zero time (long before any real commit) → no commits
612 zero := time.Time{}
613 none2, err := repo.CommitLog("main", "", 10, "", nil, &zero)
614 require.NoError(t, err)
615 require.Empty(t, none2, "until=zero should return no commits")
616
617 // Both bounds open → all commits returned (filtering is a no-op)
618 all, err := repo.CommitLog("main", "", 10, "", nil, nil)
619 require.NoError(t, err)
620 require.Len(t, all, 3, "nil since/until should return all commits")
621
622 // since = far past and until = far future → all commits still returned
623 past := time.Unix(0, 0)
624 all2, err := repo.CommitLog("main", "", 10, "", &past, &future)
625 require.NoError(t, err)
626 require.Len(t, all2, 3, "wide since/until bounds should return all commits")
627 })
628
629 // ── LastCommitForEntries ──────────────────────────────────────────────────
630
631 t.Run("LastCommitForEntries", func(t *testing.T) {
632 result, err := repo.LastCommitForEntries("main", "", []string{"README.md", "main.go", "src"})
633 require.NoError(t, err)
634
635 // README.md was last changed in c3
636 require.Equal(t, c3, result["README.md"].Hash)
637 // main.go was last changed in c2
638 require.Equal(t, c2, result["main.go"].Hash)
639 // src tree changed in c2 (util.go added)
640 require.Equal(t, c2, result["src"].Hash)
641
642 // subdirectory: last commits for entries in src/
643 srcResult, err := repo.LastCommitForEntries("main", "src", []string{"lib.go", "util.go"})
644 require.NoError(t, err)
645 // lib.go was added in c1 and never changed
646 require.Equal(t, c1, srcResult["lib.go"].Hash)
647 // util.go was added in c2
648 require.Equal(t, c2, srcResult["util.go"].Hash)
649
650 // requesting a name that doesn't exist returns no entry for it
651 partial, err := repo.LastCommitForEntries("main", "", []string{"README.md", "ghost.txt"})
652 require.NoError(t, err)
653 require.Contains(t, partial, "README.md")
654 require.NotContains(t, partial, "ghost.txt")
655 })
656
657 t.Run("LastCommitForEntries/cache-subset", func(t *testing.T) {
658 // First call with one name — seeds (or hits) the cache for this directory.
659 r1, err := repo.LastCommitForEntries("main", "", []string{"README.md"})
660 require.NoError(t, err)
661 require.Contains(t, r1, "README.md")
662 require.Equal(t, c3, r1["README.md"].Hash)
663
664 // Second call for the same directory but a different name.
665 // A buggy implementation that caches only the requested subset would
666 // return an empty map here (cache hit, but "main.go" was never stored).
667 r2, err := repo.LastCommitForEntries("main", "", []string{"main.go"})
668 require.NoError(t, err)
669 require.Contains(t, r2, "main.go", "second call with different name should hit correct result, not empty cache")
670 require.Equal(t, c2, r2["main.go"].Hash)
671
672 // Third call requesting both names should also work.
673 r3, err := repo.LastCommitForEntries("main", "", []string{"README.md", "main.go"})
674 require.NoError(t, err)
675 require.Equal(t, c3, r3["README.md"].Hash)
676 require.Equal(t, c2, r3["main.go"].Hash)
677 })
678
679 // ── CommitDetail ──────────────────────────────────────────────────────────
680
681 t.Run("CommitDetail", func(t *testing.T) {
682 detail, err := repo.CommitDetail(c2)
683 require.NoError(t, err)
684 require.Equal(t, c2, detail.Hash)
685 require.Equal(t, []Hash{c1}, detail.Parents)
686
687 filesByPath := make(map[string]ChangedFile)
688 for _, f := range detail.Files {
689 filesByPath[f.Path] = f
690 }
691 require.Equal(t, ChangeStatusModified, filesByPath["main.go"].Status)
692 require.Equal(t, ChangeStatusAdded, filesByPath["src/util.go"].Status)
693
694 // initial commit: diffs against empty tree, everything is "added"
695 initDetail, err := repo.CommitDetail(c1)
696 require.NoError(t, err)
697 for _, f := range initDetail.Files {
698 require.Equal(t, ChangeStatusAdded, f.Status, "file %s", f.Path)
699 }
700
701 // unknown hash
702 _, err = repo.CommitDetail(randomHash())
703 require.ErrorIs(t, err, ErrNotFound)
704 })
705
706 // ── CommitFileDiff ────────────────────────────────────────────────────────
707
708 t.Run("CommitFileDiff", func(t *testing.T) {
709 fd, err := repo.CommitFileDiff(c2, "main.go")
710 require.NoError(t, err)
711 require.Equal(t, "main.go", fd.Path)
712 require.False(t, fd.IsBinary)
713 require.False(t, fd.IsNew)
714 require.False(t, fd.IsDelete)
715 require.NotEmpty(t, fd.Hunks)
716
717 // find the added lines
718 var addedContent []string
719 for _, h := range fd.Hunks {
720 for _, l := range h.Lines {
721 if l.Type == DiffLineAdded {
722 addedContent = append(addedContent, l.Content)
723 }
724 }
725 }
726 require.Contains(t, addedContent, "// updated")
727
728 // new file in initial commit
729 initFD, err := repo.CommitFileDiff(c1, "main.go")
730 require.NoError(t, err)
731 require.True(t, initFD.IsNew)
732 require.Equal(t, "main.go", initFD.Path)
733
734 // file not in this commit's diff
735 _, err = repo.CommitFileDiff(c3, "main.go")
736 require.ErrorIs(t, err, ErrNotFound)
737
738 // unknown hash
739 _, err = repo.CommitFileDiff(randomHash(), "main.go")
740 require.ErrorIs(t, err, ErrNotFound)
741 })
742}