repo_testing.go

  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}