1package blob
2
3import (
4	"bytes"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"path/filepath"
10	"sort"
11	"strings"
12	"testing"
13
14	"github.com/golang/protobuf/proto"
15	"github.com/stretchr/testify/require"
16	"gitlab.com/gitlab-org/gitaly/v14/internal/git"
17	"gitlab.com/gitlab-org/gitaly/v14/internal/git/gittest"
18	"gitlab.com/gitlab-org/gitaly/v14/internal/git/localrepo"
19	"gitlab.com/gitlab-org/gitaly/v14/internal/helper/chunk"
20	"gitlab.com/gitlab-org/gitaly/v14/internal/helper/text"
21	"gitlab.com/gitlab-org/gitaly/v14/internal/testhelper"
22	"gitlab.com/gitlab-org/gitaly/v14/internal/testhelper/testcfg"
23	"gitlab.com/gitlab-org/gitaly/v14/proto/go/gitalypb"
24	"google.golang.org/grpc/codes"
25	"google.golang.org/grpc/status"
26)
27
28const (
29	lfsPointer1 = "0c304a93cb8430108629bbbcaa27db3343299bc0"
30	lfsPointer2 = "f78df813119a79bfbe0442ab92540a61d3ab7ff3"
31	lfsPointer3 = "bab31d249f78fba464d1b75799aad496cc07fa3b"
32	lfsPointer4 = "125fcc9f6e33175cb278b9b2809154d2535fe19f"
33	lfsPointer5 = "0360724a0d64498331888f1eaef2d24243809230"
34	lfsPointer6 = "ff0ab3afd1616ff78d0331865d922df103b64cf0"
35)
36
37var (
38	lfsPointers = map[string]*gitalypb.LFSPointer{
39		lfsPointer1: &gitalypb.LFSPointer{
40			Size: 133,
41			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\nsize 1575078\n\n"),
42			Oid:  lfsPointer1,
43		},
44		lfsPointer2: &gitalypb.LFSPointer{
45			Size: 127,
46			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:f2b0a1e7550e9b718dafc9b525a04879a766de62e4fbdfc46593d47f7ab74636\nsize 20\n"),
47			Oid:  lfsPointer2,
48		},
49		lfsPointer3: &gitalypb.LFSPointer{
50			Size: 127,
51			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:bad71f905b60729f502ca339f7c9f001281a3d12c68a5da7f15de8009f4bd63d\nsize 18\n"),
52			Oid:  lfsPointer3,
53		},
54		lfsPointer4: &gitalypb.LFSPointer{
55			Size: 129,
56			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:47997ea7ecff33be61e3ca1cc287ee72a2125161518f1a169f2893a5a82e9d95\nsize 7501\n"),
57			Oid:  lfsPointer4,
58		},
59		lfsPointer5: &gitalypb.LFSPointer{
60			Size: 129,
61			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:8c1e8de917525f83104736f6c64d32f0e2a02f5bf2ee57843a54f222cba8c813\nsize 2797\n"),
62			Oid:  lfsPointer5,
63		},
64		lfsPointer6: &gitalypb.LFSPointer{
65			Size: 132,
66			Data: []byte("version https://git-lfs.github.com/spec/v1\noid sha256:96f74c6fe7a2979eefb9ec74a5dfc6888fb25543cf99b77586b79afea1da6f97\nsize 1219696\n"),
67			Oid:  lfsPointer6,
68		},
69	}
70)
71
72func TestListLFSPointers(t *testing.T) {
73	_, repo, _, client := setup(t)
74
75	ctx, cancel := testhelper.Context()
76	defer cancel()
77
78	for _, tc := range []struct {
79		desc             string
80		revs             []string
81		limit            int32
82		expectedPointers []*gitalypb.LFSPointer
83		expectedErr      error
84	}{
85		{
86			desc:        "missing revisions",
87			revs:        []string{},
88			expectedErr: status.Error(codes.InvalidArgument, "missing revisions"),
89		},
90		{
91			desc:        "invalid revision",
92			revs:        []string{"-dashed"},
93			expectedErr: status.Error(codes.InvalidArgument, "invalid revision: \"-dashed\""),
94		},
95		{
96			desc: "object IDs",
97			revs: []string{
98				lfsPointer1,
99				lfsPointer2,
100				lfsPointer3,
101				"d5b560e9c17384cf8257347db63167b54e0c97ff", // tree
102				"60ecb67744cb56576c30214ff52294f8ce2def98", // commit
103			},
104			expectedPointers: []*gitalypb.LFSPointer{
105				lfsPointers[lfsPointer1],
106				lfsPointers[lfsPointer2],
107				lfsPointers[lfsPointer3],
108			},
109		},
110		{
111			desc: "revision",
112			revs: []string{"refs/heads/master"},
113			expectedPointers: []*gitalypb.LFSPointer{
114				lfsPointers[lfsPointer1],
115			},
116		},
117		{
118			desc: "pseudo-revisions",
119			revs: []string{"refs/heads/master", "--not", "--all"},
120		},
121		{
122			desc: "partial graph walk",
123			revs: []string{"--all", "--not", "refs/heads/master"},
124			expectedPointers: []*gitalypb.LFSPointer{
125				lfsPointers[lfsPointer2],
126				lfsPointers[lfsPointer3],
127				lfsPointers[lfsPointer4],
128				lfsPointers[lfsPointer5],
129				lfsPointers[lfsPointer6],
130			},
131		},
132		{
133			desc:  "partial graph walk with matching limit",
134			revs:  []string{"--all", "--not", "refs/heads/master"},
135			limit: 5,
136			expectedPointers: []*gitalypb.LFSPointer{
137				lfsPointers[lfsPointer2],
138				lfsPointers[lfsPointer3],
139				lfsPointers[lfsPointer4],
140				lfsPointers[lfsPointer5],
141				lfsPointers[lfsPointer6],
142			},
143		},
144		{
145			desc:  "partial graph walk with limiting limit",
146			revs:  []string{"--all", "--not", "refs/heads/master"},
147			limit: 3,
148			expectedPointers: []*gitalypb.LFSPointer{
149				lfsPointers[lfsPointer4],
150				lfsPointers[lfsPointer5],
151				lfsPointers[lfsPointer6],
152			},
153		},
154	} {
155		t.Run(tc.desc, func(t *testing.T) {
156			stream, err := client.ListLFSPointers(ctx, &gitalypb.ListLFSPointersRequest{
157				Repository: repo,
158				Revisions:  tc.revs,
159				Limit:      tc.limit,
160			})
161			require.NoError(t, err)
162
163			var actualLFSPointers []*gitalypb.LFSPointer
164			for {
165				resp, err := stream.Recv()
166				if err == io.EOF {
167					break
168				}
169				require.Equal(t, err, tc.expectedErr)
170				if err != nil {
171					break
172				}
173
174				actualLFSPointers = append(actualLFSPointers, resp.GetLfsPointers()...)
175			}
176			lfsPointersEqual(t, tc.expectedPointers, actualLFSPointers)
177		})
178	}
179}
180
181func TestListAllLFSPointers(t *testing.T) {
182	receivePointers := func(t *testing.T, stream gitalypb.BlobService_ListAllLFSPointersClient) []*gitalypb.LFSPointer {
183		t.Helper()
184
185		var pointers []*gitalypb.LFSPointer
186		for {
187			resp, err := stream.Recv()
188			if err == io.EOF {
189				break
190			}
191			require.Nil(t, err)
192			pointers = append(pointers, resp.GetLfsPointers()...)
193		}
194		return pointers
195	}
196
197	ctx, cancel := testhelper.Context()
198	defer cancel()
199
200	lfsPointerContents := `version https://git-lfs.github.com/spec/v1
201oid sha256:1111111111111111111111111111111111111111111111111111111111111111
202size 12345`
203
204	t.Run("normal repository", func(t *testing.T) {
205		_, repo, _, client := setup(t)
206		stream, err := client.ListAllLFSPointers(ctx, &gitalypb.ListAllLFSPointersRequest{
207			Repository: repo,
208		})
209		require.NoError(t, err)
210		lfsPointersEqual(t, []*gitalypb.LFSPointer{
211			lfsPointers[lfsPointer1],
212			lfsPointers[lfsPointer2],
213			lfsPointers[lfsPointer3],
214			lfsPointers[lfsPointer4],
215			lfsPointers[lfsPointer5],
216			lfsPointers[lfsPointer6],
217		}, receivePointers(t, stream))
218	})
219
220	t.Run("dangling LFS pointer", func(t *testing.T) {
221		cfg, repo, repoPath, client := setup(t)
222
223		hash := gittest.ExecStream(t, cfg, strings.NewReader(lfsPointerContents), "-C", repoPath, "hash-object", "-w", "--stdin")
224		lfsPointerOID := text.ChompBytes(hash)
225
226		stream, err := client.ListAllLFSPointers(ctx, &gitalypb.ListAllLFSPointersRequest{
227			Repository: repo,
228		})
229		require.NoError(t, err)
230		lfsPointersEqual(t, []*gitalypb.LFSPointer{
231			&gitalypb.LFSPointer{
232				Oid:  lfsPointerOID,
233				Data: []byte(lfsPointerContents),
234				Size: int64(len(lfsPointerContents)),
235			},
236			lfsPointers[lfsPointer1],
237			lfsPointers[lfsPointer2],
238			lfsPointers[lfsPointer3],
239			lfsPointers[lfsPointer4],
240			lfsPointers[lfsPointer5],
241			lfsPointers[lfsPointer6],
242		}, receivePointers(t, stream))
243	})
244
245	t.Run("quarantine", func(t *testing.T) {
246		cfg, repoProto, repoPath, client := setup(t)
247
248		// We're emulating the case where git is receiving data via a push, where objects
249		// are stored in a separate quarantine environment. In this case, LFS pointer checks
250		// may want to inspect all newly pushed objects, denoted by a repository proto
251		// message which only has its object directory set to the quarantine directory.
252		quarantineDir := "objects/incoming-123456"
253		require.NoError(t, os.Mkdir(filepath.Join(repoPath, quarantineDir), 0777))
254		repoProto.GitObjectDirectory = quarantineDir
255		repoProto.GitAlternateObjectDirectories = nil
256
257		// There are no quarantined objects yet, so none should be returned here.
258		stream, err := client.ListAllLFSPointers(ctx, &gitalypb.ListAllLFSPointersRequest{
259			Repository: repoProto,
260		})
261		require.NoError(t, err)
262		require.Empty(t, receivePointers(t, stream))
263
264		// Write a new object into the repository. Because we set GIT_OBJECT_DIRECTORY to
265		// the quarantine directory, objects will be written in there instead of into the
266		// repository's normal object directory.
267		repo := localrepo.NewTestRepo(t, cfg, repoProto)
268		var buffer, stderr bytes.Buffer
269		err = repo.ExecAndWait(ctx, git.SubCmd{
270			Name: "hash-object",
271			Flags: []git.Option{
272				git.Flag{Name: "-w"},
273				git.Flag{Name: "--stdin"},
274			},
275		}, git.WithStdin(strings.NewReader(lfsPointerContents)), git.WithStdout(&buffer), git.WithStderr(&stderr))
276		require.NoError(t, err)
277
278		stream, err = client.ListAllLFSPointers(ctx, &gitalypb.ListAllLFSPointersRequest{
279			Repository: repoProto,
280		})
281		require.NoError(t, err)
282
283		// We only expect to find a single LFS pointer, which is the one we've just written
284		// into the quarantine directory.
285		lfsPointersEqual(t, []*gitalypb.LFSPointer{
286			&gitalypb.LFSPointer{
287				Oid:  text.ChompBytes(buffer.Bytes()),
288				Data: []byte(lfsPointerContents),
289				Size: int64(len(lfsPointerContents)),
290			},
291		}, receivePointers(t, stream))
292	})
293}
294
295func TestSuccessfulGetLFSPointersRequest(t *testing.T) {
296	_, repo, _, client := setup(t)
297
298	ctx, cancel := testhelper.Context()
299	defer cancel()
300
301	lfsPointerIds := []string{
302		lfsPointer1,
303		lfsPointer2,
304		lfsPointer3,
305	}
306	otherObjectIds := []string{
307		"d5b560e9c17384cf8257347db63167b54e0c97ff", // tree
308		"60ecb67744cb56576c30214ff52294f8ce2def98", // commit
309	}
310
311	expectedLFSPointers := []*gitalypb.LFSPointer{
312		lfsPointers[lfsPointer1],
313		lfsPointers[lfsPointer2],
314		lfsPointers[lfsPointer3],
315	}
316
317	request := &gitalypb.GetLFSPointersRequest{
318		Repository: repo,
319		BlobIds:    append(lfsPointerIds, otherObjectIds...),
320	}
321
322	stream, err := client.GetLFSPointers(ctx, request)
323	require.NoError(t, err)
324
325	var receivedLFSPointers []*gitalypb.LFSPointer
326	for {
327		resp, err := stream.Recv()
328		if err == io.EOF {
329			break
330		} else if err != nil {
331			t.Fatal(err)
332		}
333
334		receivedLFSPointers = append(receivedLFSPointers, resp.GetLfsPointers()...)
335	}
336
337	lfsPointersEqual(t, receivedLFSPointers, expectedLFSPointers)
338}
339
340func TestFailedGetLFSPointersRequestDueToValidations(t *testing.T) {
341	_, repo, _, client := setup(t)
342
343	ctx, cancel := testhelper.Context()
344	defer cancel()
345
346	testCases := []struct {
347		desc    string
348		request *gitalypb.GetLFSPointersRequest
349		code    codes.Code
350	}{
351		{
352			desc: "empty Repository",
353			request: &gitalypb.GetLFSPointersRequest{
354				Repository: nil,
355				BlobIds:    []string{"f00"},
356			},
357			code: codes.InvalidArgument,
358		},
359		{
360			desc: "empty BlobIds",
361			request: &gitalypb.GetLFSPointersRequest{
362				Repository: repo,
363				BlobIds:    nil,
364			},
365			code: codes.InvalidArgument,
366		},
367	}
368
369	for _, testCase := range testCases {
370		t.Run(testCase.desc, func(t *testing.T) {
371			stream, err := client.GetLFSPointers(ctx, testCase.request)
372			require.NoError(t, err)
373
374			_, err = stream.Recv()
375			require.NotEqual(t, io.EOF, err)
376			testhelper.RequireGrpcError(t, err, testCase.code)
377		})
378	}
379}
380
381func TestFindLFSPointersByRevisions(t *testing.T) {
382	cfg := testcfg.Build(t)
383
384	gitCmdFactory := git.NewExecCommandFactory(cfg)
385
386	repoProto, _, cleanup := gittest.CloneRepoAtStorage(t, cfg, cfg.Storages[0], t.Name())
387	t.Cleanup(cleanup)
388	repo := localrepo.NewTestRepo(t, cfg, repoProto)
389
390	ctx, cancel := testhelper.Context()
391	defer cancel()
392
393	for _, tc := range []struct {
394		desc                string
395		revs                []string
396		limit               int
397		expectedErr         error
398		expectedLFSPointers []*gitalypb.LFSPointer
399	}{
400		{
401			desc: "--all",
402			revs: []string{"--all"},
403			expectedLFSPointers: []*gitalypb.LFSPointer{
404				lfsPointers[lfsPointer1],
405				lfsPointers[lfsPointer2],
406				lfsPointers[lfsPointer3],
407				lfsPointers[lfsPointer4],
408				lfsPointers[lfsPointer5],
409				lfsPointers[lfsPointer6],
410			},
411		},
412		{
413			desc:  "--all with high limit",
414			revs:  []string{"--all"},
415			limit: 7,
416			expectedLFSPointers: []*gitalypb.LFSPointer{
417				lfsPointers[lfsPointer1],
418				lfsPointers[lfsPointer2],
419				lfsPointers[lfsPointer3],
420				lfsPointers[lfsPointer4],
421				lfsPointers[lfsPointer5],
422				lfsPointers[lfsPointer6],
423			},
424		},
425		{
426			desc:  "--all with truncating limit",
427			revs:  []string{"--all"},
428			limit: 3,
429			expectedLFSPointers: []*gitalypb.LFSPointer{
430				lfsPointers[lfsPointer1],
431				lfsPointers[lfsPointer5],
432				lfsPointers[lfsPointer6],
433			},
434			expectedErr: errLimitReached,
435		},
436		{
437			desc: "--not --all",
438			revs: []string{"--not", "--all"},
439		},
440		{
441			desc: "initial commit",
442			revs: []string{"1a0b36b3cdad1d2ee32457c102a8c0b7056fa863"},
443		},
444		{
445			desc: "master",
446			revs: []string{"master"},
447			expectedLFSPointers: []*gitalypb.LFSPointer{
448				lfsPointers[lfsPointer1],
449			},
450		},
451		{
452			desc: "multiple revisions",
453			revs: []string{"master", "moar-lfs-ptrs"},
454			expectedLFSPointers: []*gitalypb.LFSPointer{
455				lfsPointers[lfsPointer1],
456				lfsPointers[lfsPointer2],
457				lfsPointers[lfsPointer3],
458			},
459		},
460		{
461			desc:        "invalid dashed option",
462			revs:        []string{"master", "--foobar"},
463			expectedErr: fmt.Errorf("invalid revision: \"--foobar\""),
464		},
465		{
466			desc:        "invalid revision",
467			revs:        []string{"does-not-exist"},
468			expectedErr: fmt.Errorf("fatal: ambiguous argument 'does-not-exist'"),
469		},
470	} {
471		t.Run(tc.desc, func(t *testing.T) {
472			var collector lfsPointerCollector
473
474			err := findLFSPointersByRevisions(ctx, repo, gitCmdFactory,
475				collector.chunker(), tc.limit, tc.revs...)
476			if tc.expectedErr == nil {
477				require.NoError(t, err)
478			} else {
479				require.Contains(t, err.Error(), tc.expectedErr.Error())
480			}
481			lfsPointersEqual(t, tc.expectedLFSPointers, collector.pointers)
482		})
483	}
484}
485
486func BenchmarkFindLFSPointers(b *testing.B) {
487	cfg := testcfg.Build(b)
488
489	gitCmdFactory := git.NewExecCommandFactory(cfg)
490
491	repoProto, _, cleanup := gittest.CloneBenchRepo(b, cfg)
492	b.Cleanup(cleanup)
493	repo := localrepo.NewTestRepo(b, cfg, repoProto)
494
495	ctx, cancel := testhelper.Context()
496	defer cancel()
497
498	b.Run("limitless", func(b *testing.B) {
499		var collector lfsPointerCollector
500		err := findLFSPointersByRevisions(ctx, repo, gitCmdFactory, collector.chunker(), 0, "--all")
501		require.NoError(b, err)
502	})
503
504	b.Run("limit", func(b *testing.B) {
505		var collector lfsPointerCollector
506		err := findLFSPointersByRevisions(ctx, repo, gitCmdFactory, collector.chunker(), 1, "--all")
507		require.NoError(b, err)
508		require.Len(b, collector.pointers, 1)
509	})
510}
511
512func BenchmarkReadLFSPointers(b *testing.B) {
513	cfg := testcfg.Build(b)
514
515	repoProto, path, cleanup := gittest.CloneBenchRepo(b, cfg)
516	b.Cleanup(cleanup)
517	repo := localrepo.NewTestRepo(b, cfg, repoProto)
518
519	ctx, cancel := testhelper.Context()
520	defer cancel()
521
522	candidates := gittest.Exec(b, cfg, "-C", path, "rev-list", "--in-commit-order", "--objects", "--no-object-names", "--filter=blob:limit=200", "--all")
523
524	b.Run("limitless", func(b *testing.B) {
525		var collector lfsPointerCollector
526		err := readLFSPointers(ctx, repo, collector.chunker(), bytes.NewReader(candidates), 0)
527		require.NoError(b, err)
528	})
529
530	b.Run("limit", func(b *testing.B) {
531		var collector lfsPointerCollector
532		err := readLFSPointers(ctx, repo, collector.chunker(), bytes.NewReader(candidates), 1)
533		require.Equal(b, errLimitReached, err)
534		require.Equal(b, 1, len(collector.pointers))
535	})
536}
537
538func TestReadLFSPointers(t *testing.T) {
539	cfg, repo, _, _ := setup(t)
540
541	localRepo := localrepo.NewTestRepo(t, cfg, repo)
542
543	ctx, cancel := testhelper.Context()
544	defer cancel()
545
546	for _, tc := range []struct {
547		desc                string
548		input               string
549		limit               int
550		expectedErr         error
551		expectedLFSPointers []*gitalypb.LFSPointer
552	}{
553		{
554			desc:  "single object ID",
555			input: strings.Join([]string{lfsPointer1}, "\n"),
556			expectedLFSPointers: []*gitalypb.LFSPointer{
557				lfsPointers[lfsPointer1],
558			},
559		},
560		{
561			desc: "multiple object IDs",
562			input: strings.Join([]string{
563				lfsPointer1,
564				lfsPointer2,
565				lfsPointer3,
566				lfsPointer4,
567				lfsPointer5,
568				lfsPointer6,
569			}, "\n"),
570			expectedLFSPointers: []*gitalypb.LFSPointer{
571				lfsPointers[lfsPointer1],
572				lfsPointers[lfsPointer2],
573				lfsPointers[lfsPointer3],
574				lfsPointers[lfsPointer4],
575				lfsPointers[lfsPointer5],
576				lfsPointers[lfsPointer6],
577			},
578		},
579		{
580			desc: "multiple object IDs with high limit",
581			input: strings.Join([]string{
582				lfsPointer1,
583				lfsPointer2,
584				lfsPointer3,
585				lfsPointer4,
586				lfsPointer5,
587				lfsPointer6,
588			}, "\n"),
589			limit: 7,
590			expectedLFSPointers: []*gitalypb.LFSPointer{
591				lfsPointers[lfsPointer1],
592				lfsPointers[lfsPointer2],
593				lfsPointers[lfsPointer3],
594				lfsPointers[lfsPointer4],
595				lfsPointers[lfsPointer5],
596				lfsPointers[lfsPointer6],
597			},
598		},
599		{
600			desc: "multiple object IDs with truncating limit",
601			input: strings.Join([]string{
602				lfsPointer1,
603				lfsPointer2,
604				lfsPointer3,
605				lfsPointer4,
606				lfsPointer5,
607				lfsPointer6,
608			}, "\n"),
609			limit: 3,
610			expectedLFSPointers: []*gitalypb.LFSPointer{
611				lfsPointers[lfsPointer1],
612				lfsPointers[lfsPointer2],
613				lfsPointers[lfsPointer3],
614			},
615			expectedErr: errLimitReached,
616		},
617		{
618			desc: "multiple object IDs with name filter",
619			input: strings.Join([]string{
620				lfsPointer1,
621				lfsPointer2,
622				lfsPointer3 + " x",
623				lfsPointer4,
624				lfsPointer5 + " z",
625				lfsPointer6 + " a",
626			}, "\n"),
627			expectedLFSPointers: []*gitalypb.LFSPointer{
628				lfsPointers[lfsPointer1],
629				lfsPointers[lfsPointer2],
630			},
631			expectedErr: errors.New("object not found"),
632		},
633		{
634			desc: "non-pointer object",
635			input: strings.Join([]string{
636				"60ecb67744cb56576c30214ff52294f8ce2def98",
637			}, "\n"),
638		},
639		{
640			desc: "mixed objects",
641			input: strings.Join([]string{
642				"60ecb67744cb56576c30214ff52294f8ce2def98",
643				lfsPointer2,
644			}, "\n"),
645			expectedLFSPointers: []*gitalypb.LFSPointer{
646				lfsPointers[lfsPointer2],
647			},
648		},
649		{
650			desc: "missing object",
651			input: strings.Join([]string{
652				"0101010101010101010101010101010101010101",
653			}, "\n"),
654			expectedErr: errors.New("object not found"),
655		},
656	} {
657		t.Run(tc.desc, func(t *testing.T) {
658			reader := strings.NewReader(tc.input)
659
660			var collector lfsPointerCollector
661
662			err := readLFSPointers(ctx, localRepo, collector.chunker(), reader, tc.limit)
663			if tc.expectedErr == nil {
664				require.NoError(t, err)
665			} else {
666				require.Contains(t, err.Error(), tc.expectedErr.Error())
667			}
668
669			lfsPointersEqual(t, tc.expectedLFSPointers, collector.pointers)
670		})
671	}
672}
673
674func lfsPointersEqual(tb testing.TB, expected, actual []*gitalypb.LFSPointer) {
675	tb.Helper()
676
677	for _, slice := range [][]*gitalypb.LFSPointer{expected, actual} {
678		sort.Slice(slice, func(i, j int) bool {
679			return strings.Compare(slice[i].Oid, slice[j].Oid) < 0
680		})
681	}
682
683	require.Equal(tb, len(expected), len(actual))
684	for i := range expected {
685		testhelper.ProtoEqual(tb, expected[i], actual[i])
686	}
687}
688
689type lfsPointerCollector struct {
690	pointers []*gitalypb.LFSPointer
691}
692
693func (c *lfsPointerCollector) Append(m proto.Message) {
694	c.pointers = append(c.pointers, m.(*gitalypb.LFSPointer))
695}
696
697func (c *lfsPointerCollector) Reset() {
698	// We don'c reset anything given that we want to collect all pointers.
699}
700
701func (c *lfsPointerCollector) Send() error {
702	// And neither do we anything here.
703	return nil
704}
705
706func (c *lfsPointerCollector) chunker() *chunk.Chunker {
707	return chunk.New(c)
708}
709