fork of https://github.com/sourcegraph/zoekt
1package gitindex
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/go-git/go-git/v5/plumbing"
15)
16
17// --- Close lifecycle tests ---
18
19// TestCatfileReader_DoubleClose verifies that Close is idempotent.
20// Calling Close twice must not deadlock or panic.
21func TestCatfileReader_DoubleClose(t *testing.T) {
22 repoDir, blobs := createTestRepo(t)
23 ids := []plumbing.Hash{blobs["hello.txt"]}
24
25 cr, err := newCatfileReader(repoDir, ids)
26 if err != nil {
27 t.Fatal(err)
28 }
29
30 // Consume the entry so the process can exit cleanly.
31 if _, _, err := cr.Next(); err != nil {
32 t.Fatal(err)
33 }
34
35 if err := cr.Close(); err != nil {
36 t.Fatalf("first Close: %v", err)
37 }
38
39 // Second Close must not deadlock or panic.
40 done := make(chan error, 1)
41 go func() {
42 done <- cr.Close()
43 }()
44
45 select {
46 case <-done:
47 // Success — whether err is nil or not, it didn't block.
48 case <-time.After(5 * time.Second):
49 t.Fatal("second Close() deadlocked — writeErr channel was never closed")
50 }
51}
52
53// TestCatfileReader_ConcurrentClose verifies that calling Close from
54// multiple goroutines simultaneously does not panic, deadlock, or
55// corrupt state.
56func TestCatfileReader_ConcurrentClose(t *testing.T) {
57 repoDir, blobs := createTestRepo(t)
58 ids := []plumbing.Hash{
59 blobs["hello.txt"],
60 blobs["large.bin"],
61 blobs["binary.bin"],
62 }
63
64 cr, err := newCatfileReader(repoDir, ids)
65 if err != nil {
66 t.Fatal(err)
67 }
68
69 // Read one entry, leave two unconsumed.
70 if _, _, err := cr.Next(); err != nil {
71 t.Fatal(err)
72 }
73
74 const goroutines = 5
75 var wg sync.WaitGroup
76 wg.Add(goroutines)
77 barrier := make(chan struct{})
78
79 for i := 0; i < goroutines; i++ {
80 go func() {
81 defer wg.Done()
82 <-barrier // all start at once
83 cr.Close()
84 }()
85 }
86
87 done := make(chan struct{})
88 go func() {
89 close(barrier)
90 wg.Wait()
91 close(done)
92 }()
93
94 select {
95 case <-done:
96 // All goroutines returned.
97 case <-time.After(10 * time.Second):
98 t.Fatal("concurrent Close() deadlocked")
99 }
100}
101
102// TestCatfileReader_CloseWithoutReading verifies that closing
103// immediately after creation (without reading any entries) completes
104// without hanging.
105func TestCatfileReader_CloseWithoutReading(t *testing.T) {
106 repoDir, blobs := createTestRepo(t)
107 ids := []plumbing.Hash{
108 blobs["hello.txt"],
109 blobs["large.bin"],
110 blobs["binary.bin"],
111 blobs["empty.txt"],
112 }
113
114 cr, err := newCatfileReader(repoDir, ids)
115 if err != nil {
116 t.Fatal(err)
117 }
118
119 done := make(chan error, 1)
120 go func() {
121 done <- cr.Close()
122 }()
123
124 select {
125 case err := <-done:
126 if err != nil {
127 t.Fatalf("Close: %v", err)
128 }
129 case <-time.After(10 * time.Second):
130 t.Fatal("Close() without reading any entries hung")
131 }
132}
133
134// TestCatfileReader_CloseBeforeExhausted_ManyBlobs simulates early
135// termination (e.g., builder.Add error) with many unconsumed blobs.
136// Close should complete promptly — not drain the entire git output.
137func TestCatfileReader_CloseBeforeExhausted_ManyBlobs(t *testing.T) {
138 // Create a repo with many non-trivial files.
139 dir := t.TempDir()
140 repoDir := filepath.Join(dir, "repo")
141
142 script := `
143set -e
144git init -b main repo
145cd repo
146git config user.email "test@test.com"
147git config user.name "Test"
148for i in $(seq 1 200); do
149 dd if=/dev/urandom bs=1024 count=10 of="file_$i.bin" 2>/dev/null
150done
151git add -A
152git commit -m "many files"
153`
154 cmd := exec.Command("/bin/sh", "-c", script)
155 cmd.Dir = dir
156 cmd.Stderr = os.Stderr
157 if err := cmd.Run(); err != nil {
158 t.Fatalf("create test repo: %v", err)
159 }
160
161 var ids []plumbing.Hash
162 for i := 1; i <= 200; i++ {
163 name := fmt.Sprintf("file_%d.bin", i)
164 out, err := exec.Command("git", "-C", repoDir, "rev-parse", "HEAD:"+name).Output()
165 if err != nil {
166 t.Fatalf("rev-parse %s: %v", name, err)
167 }
168 ids = append(ids, plumbing.NewHash(string(out[:len(out)-1])))
169 }
170
171 cr, err := newCatfileReader(repoDir, ids)
172 if err != nil {
173 t.Fatal(err)
174 }
175
176 // Read only 1 of 200 entries.
177 if _, _, err := cr.Next(); err != nil {
178 t.Fatal(err)
179 }
180
181 // Close should be fast (kill, not drain). With drain it still works but
182 // is slow — we enforce a generous bound.
183 start := time.Now()
184 done := make(chan error, 1)
185 go func() {
186 done <- cr.Close()
187 }()
188
189 select {
190 case <-done:
191 elapsed := time.Since(start)
192 // With Kill: sub-millisecond. Draining 200×10KB is fast too, so we
193 // use a generous 3s bound that still catches pathological stalls.
194 if elapsed > 3*time.Second {
195 t.Errorf("Close took %v after reading 1 of 200 entries — consider killing instead of draining", elapsed)
196 }
197 case <-time.After(30 * time.Second):
198 t.Fatal("Close() deadlocked with many unconsumed blobs")
199 }
200}
201
202// --- Read edge-case tests ---
203
204// TestCatfileReader_ReadWithoutNext verifies that calling Read
205// before calling Next returns io.EOF, not a panic or garbage data.
206func TestCatfileReader_ReadWithoutNext(t *testing.T) {
207 repoDir, blobs := createTestRepo(t)
208 ids := []plumbing.Hash{blobs["hello.txt"]}
209
210 cr, err := newCatfileReader(repoDir, ids)
211 if err != nil {
212 t.Fatal(err)
213 }
214 defer cr.Close()
215
216 buf := make([]byte, 10)
217 n, err := cr.Read(buf)
218 if n != 0 || err != io.EOF {
219 t.Fatalf("Read without Next: n=%d err=%v, want n=0 err=io.EOF", n, err)
220 }
221}
222
223// TestCatfileReader_ReadAfterFullConsumption verifies that extra Read
224// calls after a blob is fully consumed return io.EOF, not duplicate
225// data or trailing LF bytes.
226func TestCatfileReader_ReadAfterFullConsumption(t *testing.T) {
227 repoDir, blobs := createTestRepo(t)
228 ids := []plumbing.Hash{blobs["hello.txt"]}
229
230 cr, err := newCatfileReader(repoDir, ids)
231 if err != nil {
232 t.Fatal(err)
233 }
234 defer cr.Close()
235
236 size, _, _ := cr.Next()
237 content := make([]byte, size)
238 if _, err := io.ReadFull(cr, content); err != nil {
239 t.Fatal(err)
240 }
241
242 // Blob is fully read — additional Reads must return EOF.
243 for i := 0; i < 3; i++ {
244 buf := make([]byte, 10)
245 n, err := cr.Read(buf)
246 if n != 0 || err != io.EOF {
247 t.Fatalf("Read #%d after full consumption: n=%d err=%v, want n=0 err=io.EOF", i, n, err)
248 }
249 }
250}
251
252// TestCatfileReader_SmallBufferReads reads a blob one byte at a time
253// and verifies the entire content is reconstructed correctly without
254// any trailing LF leaking into user content.
255func TestCatfileReader_SmallBufferReads(t *testing.T) {
256 repoDir, blobs := createTestRepo(t)
257 ids := []plumbing.Hash{blobs["hello.txt"]}
258
259 cr, err := newCatfileReader(repoDir, ids)
260 if err != nil {
261 t.Fatal(err)
262 }
263 defer cr.Close()
264
265 size, _, _ := cr.Next()
266
267 var result []byte
268 buf := make([]byte, 1)
269 for {
270 n, err := cr.Read(buf)
271 if n > 0 {
272 result = append(result, buf[:n]...)
273 }
274 if err == io.EOF {
275 break
276 }
277 if err != nil {
278 t.Fatal(err)
279 }
280 }
281
282 if len(result) != size {
283 t.Fatalf("read %d bytes, want %d", len(result), size)
284 }
285 if string(result) != "hello world\n" {
286 t.Errorf("content = %q, want %q", result, "hello world\n")
287 }
288}
289
290// TestCatfileReader_PartialReadThenNext reads only part of a blob's
291// content, then advances to the next entry. Verifies that the discard
292// of pending bytes doesn't corrupt the stream.
293func TestCatfileReader_PartialReadThenNext(t *testing.T) {
294 repoDir, blobs := createTestRepo(t)
295 ids := []plumbing.Hash{
296 blobs["hello.txt"], // 12 bytes: "hello world\n"
297 blobs["binary.bin"], // variable, starts with 0x00
298 }
299
300 cr, err := newCatfileReader(repoDir, ids)
301 if err != nil {
302 t.Fatal(err)
303 }
304 defer cr.Close()
305
306 // Read only 5 of 12 bytes from hello.txt.
307 size, _, _ := cr.Next()
308 if size != 12 {
309 t.Fatalf("hello.txt size = %d, want 12", size)
310 }
311 partial := make([]byte, 5)
312 if _, err := io.ReadFull(cr, partial); err != nil {
313 t.Fatal(err)
314 }
315 if string(partial) != "hello" {
316 t.Fatalf("partial = %q, want %q", partial, "hello")
317 }
318
319 // Advance — must discard remaining 7 content bytes + trailing LF.
320 size, _, err = cr.Next()
321 if err != nil {
322 t.Fatalf("Next binary.bin after partial read: %v", err)
323 }
324
325 // Verify binary.bin content is intact.
326 content := make([]byte, size)
327 if _, err := io.ReadFull(cr, content); err != nil {
328 t.Fatal(err)
329 }
330 if content[0] != 0x00 {
331 t.Errorf("binary.bin first byte = 0x%02x after partial-read skip, want 0x00", content[0])
332 }
333}
334
335// TestCatfileReader_PartialReadExactlyOneByteShort reads size-1 bytes
336// from a blob. The pending field should be exactly 2 (1 content byte +
337// 1 trailing LF). This stresses the boundary between content and LF
338// in the discard path.
339func TestCatfileReader_PartialReadExactlyOneByteShort(t *testing.T) {
340 repoDir, blobs := createTestRepo(t)
341 ids := []plumbing.Hash{
342 blobs["hello.txt"], // 12 bytes
343 blobs["binary.bin"], // starts with 0x00
344 }
345
346 cr, err := newCatfileReader(repoDir, ids)
347 if err != nil {
348 t.Fatal(err)
349 }
350 defer cr.Close()
351
352 size, _, _ := cr.Next()
353 // Read exactly size-1 bytes — leaves 1 content byte + trailing LF.
354 buf := make([]byte, size-1)
355 if _, err := io.ReadFull(cr, buf); err != nil {
356 t.Fatal(err)
357 }
358 if string(buf) != "hello world" { // missing final \n
359 t.Fatalf("partial = %q", buf)
360 }
361
362 // Advance — pending should be 2 (1 content byte + 1 LF). The
363 // Discard call must handle this exact boundary correctly.
364 size, missing, err := cr.Next()
365 if err != nil {
366 t.Fatalf("Next after size-1 partial read: %v", err)
367 }
368 if missing {
369 t.Fatal("binary.bin unexpectedly missing")
370 }
371
372 // Read binary.bin to verify stream integrity.
373 content := make([]byte, size)
374 if _, err := io.ReadFull(cr, content); err != nil {
375 t.Fatal(err)
376 }
377 if content[0] != 0x00 {
378 t.Errorf("binary.bin[0] = 0x%02x after boundary skip, want 0x00", content[0])
379 }
380}
381
382// --- Empty / degenerate input tests ---
383
384// TestCatfileReader_EmptyIds verifies that an empty id slice produces
385// immediate EOF without errors.
386func TestCatfileReader_EmptyIds(t *testing.T) {
387 repoDir, _ := createTestRepo(t)
388
389 cr, err := newCatfileReader(repoDir, nil)
390 if err != nil {
391 t.Fatal(err)
392 }
393 defer cr.Close()
394
395 _, _, err = cr.Next()
396 if err != io.EOF {
397 t.Fatalf("expected io.EOF for empty ids, got %v", err)
398 }
399}
400
401// TestCatfileReader_MultipleEmptyBlobs stresses the trailing-LF
402// handling for size-0 blobs. Git still outputs a LF after a 0-byte
403// blob body. Repeated empty blobs test the pending=1 discard path.
404func TestCatfileReader_MultipleEmptyBlobs(t *testing.T) {
405 repoDir, blobs := createTestRepo(t)
406
407 // Send the empty blob SHA 5 times — git outputs each independently.
408 emptyID := blobs["empty.txt"]
409 ids := []plumbing.Hash{emptyID, emptyID, emptyID, emptyID, emptyID}
410
411 cr, err := newCatfileReader(repoDir, ids)
412 if err != nil {
413 t.Fatal(err)
414 }
415 defer cr.Close()
416
417 for i := range ids {
418 size, missing, err := cr.Next()
419 if err != nil {
420 t.Fatalf("Next #%d: %v", i, err)
421 }
422 if missing {
423 t.Fatalf("#%d unexpectedly missing", i)
424 }
425 if size != 0 {
426 t.Fatalf("#%d size = %d, want 0", i, size)
427 }
428 // Don't read — Next should discard the trailing LF for us.
429 }
430
431 _, _, err = cr.Next()
432 if err != io.EOF {
433 t.Fatalf("expected EOF after %d empty blobs, got %v", len(ids), err)
434 }
435}
436
437// TestCatfileReader_EmptyBlobRead verifies that reading a 0-byte blob
438// through the io.Reader interface returns 0 bytes and io.EOF, and that
439// the trailing LF is consumed transparently.
440func TestCatfileReader_EmptyBlobRead(t *testing.T) {
441 repoDir, blobs := createTestRepo(t)
442 ids := []plumbing.Hash{
443 blobs["empty.txt"], // 0 bytes
444 blobs["hello.txt"], // 12 bytes — sentinel
445 }
446
447 cr, err := newCatfileReader(repoDir, ids)
448 if err != nil {
449 t.Fatal(err)
450 }
451 defer cr.Close()
452
453 size, _, _ := cr.Next()
454 if size != 0 {
455 t.Fatalf("empty.txt size = %d", size)
456 }
457
458 // Explicitly Read on the 0-byte blob.
459 buf := make([]byte, 10)
460 n, err := cr.Read(buf)
461 if n != 0 || err != io.EOF {
462 t.Fatalf("Read empty blob: n=%d err=%v, want n=0 err=io.EOF", n, err)
463 }
464
465 // The trailing LF must have been consumed. Verify by reading the
466 // next entry — if the LF leaked, the header parse would fail.
467 size, _, err = cr.Next()
468 if err != nil {
469 t.Fatalf("Next hello.txt after empty blob Read: %v", err)
470 }
471 if size != 12 {
472 t.Fatalf("hello.txt size = %d, want 12", size)
473 }
474 content := make([]byte, size)
475 if _, err := io.ReadFull(cr, content); err != nil {
476 t.Fatal(err)
477 }
478 if string(content) != "hello world\n" {
479 t.Errorf("hello.txt = %q", content)
480 }
481}
482
483// --- Missing object edge cases ---
484
485// TestCatfileReader_AllMissing verifies that a sequence of entirely
486// missing objects is handled gracefully — no errors, no panics, just
487// missing=true for each followed by EOF.
488func TestCatfileReader_AllMissing(t *testing.T) {
489 repoDir, _ := createTestRepo(t)
490
491 ids := []plumbing.Hash{
492 plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
493 plumbing.NewHash("1111111111111111111111111111111111111111"),
494 plumbing.NewHash("2222222222222222222222222222222222222222"),
495 }
496
497 cr, err := newCatfileReader(repoDir, ids)
498 if err != nil {
499 t.Fatal(err)
500 }
501 defer cr.Close()
502
503 for i, id := range ids {
504 _, missing, err := cr.Next()
505 if err != nil {
506 t.Fatalf("Next #%d (%s): %v", i, id, err)
507 }
508 if !missing {
509 t.Errorf("expected #%d (%s) to be missing", i, id)
510 }
511 }
512
513 _, _, err = cr.Next()
514 if err != io.EOF {
515 t.Fatalf("expected EOF after all missing, got %v", err)
516 }
517}
518
519// TestCatfileReader_AlternatingMissingPresent interleaves missing and
520// present objects, verifying that stream alignment is maintained.
521func TestCatfileReader_AlternatingMissingPresent(t *testing.T) {
522 repoDir, blobs := createTestRepo(t)
523
524 fake1 := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
525 fake2 := plumbing.NewHash("1111111111111111111111111111111111111111")
526
527 ids := []plumbing.Hash{
528 fake1,
529 blobs["hello.txt"],
530 fake2,
531 blobs["empty.txt"],
532 blobs["binary.bin"],
533 }
534
535 cr, err := newCatfileReader(repoDir, ids)
536 if err != nil {
537 t.Fatal(err)
538 }
539 defer cr.Close()
540
541 // fake1 — missing
542 _, missing, err := cr.Next()
543 if err != nil || !missing {
544 t.Fatalf("fake1: err=%v missing=%v", err, missing)
545 }
546
547 // hello.txt — present, read it
548 size, missing, err := cr.Next()
549 if err != nil || missing {
550 t.Fatalf("hello.txt: err=%v missing=%v", err, missing)
551 }
552 content := make([]byte, size)
553 if _, err := io.ReadFull(cr, content); err != nil {
554 t.Fatal(err)
555 }
556 if string(content) != "hello world\n" {
557 t.Errorf("hello.txt = %q", content)
558 }
559
560 // fake2 — missing
561 _, missing, err = cr.Next()
562 if err != nil || !missing {
563 t.Fatalf("fake2: err=%v missing=%v", err, missing)
564 }
565
566 // empty.txt — present, skip it
567 size, missing, err = cr.Next()
568 if err != nil || missing {
569 t.Fatalf("empty.txt: err=%v missing=%v", err, missing)
570 }
571 if size != 0 {
572 t.Errorf("empty.txt size = %d", size)
573 }
574
575 // binary.bin — present, read it
576 size, missing, err = cr.Next()
577 if err != nil || missing {
578 t.Fatalf("binary.bin: err=%v missing=%v", err, missing)
579 }
580 binContent := make([]byte, size)
581 if _, err := io.ReadFull(cr, binContent); err != nil {
582 t.Fatal(err)
583 }
584 if binContent[0] != 0x00 {
585 t.Errorf("binary.bin[0] = 0x%02x, want 0x00", binContent[0])
586 }
587
588 _, _, err = cr.Next()
589 if err != io.EOF {
590 t.Fatalf("expected EOF, got %v", err)
591 }
592}
593
594// TestCatfileReader_MissingThenSkip verifies that a missing object
595// followed by a present but skipped (unread) object doesn't corrupt
596// the stream. Missing objects have no content body, so there must be
597// no stale pending bytes interfering with the next header read.
598func TestCatfileReader_MissingThenSkip(t *testing.T) {
599 repoDir, blobs := createTestRepo(t)
600
601 fake := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
602 ids := []plumbing.Hash{
603 fake,
604 blobs["large.bin"], // 64KB — skip without reading
605 blobs["hello.txt"], // sentinel — read to verify integrity
606 }
607
608 cr, err := newCatfileReader(repoDir, ids)
609 if err != nil {
610 t.Fatal(err)
611 }
612 defer cr.Close()
613
614 // missing
615 _, missing, _ := cr.Next()
616 if !missing {
617 t.Fatal("expected missing")
618 }
619
620 // large.bin — skip
621 size, missing, err := cr.Next()
622 if err != nil || missing {
623 t.Fatalf("large.bin: err=%v missing=%v", err, missing)
624 }
625 if size != 64*1024 {
626 t.Fatalf("large.bin size = %d", size)
627 }
628 // deliberately don't read
629
630 // hello.txt — read after missing+skip
631 size, missing, err = cr.Next()
632 if err != nil || missing {
633 t.Fatalf("hello.txt: err=%v missing=%v", err, missing)
634 }
635 content := make([]byte, size)
636 if _, err := io.ReadFull(cr, content); err != nil {
637 t.Fatal(err)
638 }
639 if string(content) != "hello world\n" {
640 t.Errorf("hello.txt = %q", content)
641 }
642}
643
644// --- Next() edge cases ---
645
646// TestCatfileReader_RepeatedNextAfterEOF verifies that calling Next
647// after EOF keeps returning EOF — not a panic, not a different error.
648func TestCatfileReader_RepeatedNextAfterEOF(t *testing.T) {
649 repoDir, blobs := createTestRepo(t)
650 ids := []plumbing.Hash{blobs["hello.txt"]}
651
652 cr, err := newCatfileReader(repoDir, ids)
653 if err != nil {
654 t.Fatal(err)
655 }
656 defer cr.Close()
657
658 // Consume and skip the only entry.
659 if _, _, err := cr.Next(); err != nil {
660 t.Fatal(err)
661 }
662
663 // First EOF.
664 _, _, err = cr.Next()
665 if err != io.EOF {
666 t.Fatalf("first post-exhaust Next: %v, want io.EOF", err)
667 }
668
669 // Second and third EOF — must be stable.
670 for i := 0; i < 2; i++ {
671 _, _, err = cr.Next()
672 if err != io.EOF {
673 t.Fatalf("Next #%d after EOF: %v, want io.EOF", i+2, err)
674 }
675 }
676}
677
678// --- Large blob precision tests ---
679
680// TestCatfileReader_LargeBlobBytePrecision verifies that a 64KB blob
681// is read with byte-exact precision — no off-by-one from trailing LF
682// handling, no truncation, no extra bytes.
683func TestCatfileReader_LargeBlobBytePrecision(t *testing.T) {
684 repoDir, blobs := createTestRepo(t)
685 ids := []plumbing.Hash{blobs["large.bin"]}
686
687 cr, err := newCatfileReader(repoDir, ids)
688 if err != nil {
689 t.Fatal(err)
690 }
691 defer cr.Close()
692
693 size, _, err := cr.Next()
694 if err != nil {
695 t.Fatal(err)
696 }
697 if size != 64*1024 {
698 t.Fatalf("size = %d, want %d", size, 64*1024)
699 }
700
701 // Read the full blob content.
702 content := make([]byte, size)
703 n, err := io.ReadFull(cr, content)
704 if err != nil {
705 t.Fatalf("ReadFull: %v (read %d of %d)", err, n, size)
706 }
707 if n != size {
708 t.Fatalf("read %d bytes, want %d", n, size)
709 }
710
711 // Verify git agrees on the content via cat-file -p.
712 expected, err := exec.Command("git", "-C", repoDir, "cat-file", "-p", blobs["large.bin"].String()).Output()
713 if err != nil {
714 t.Fatalf("git cat-file -p: %v", err)
715 }
716 if !bytes.Equal(content, expected) {
717 t.Errorf("content mismatch: got %d bytes, git says %d bytes", len(content), len(expected))
718 // Find first divergence.
719 for i := range content {
720 if i >= len(expected) || content[i] != expected[i] {
721 t.Errorf("first diff at byte %d: got 0x%02x, want 0x%02x", i, content[i], expected[i])
722 break
723 }
724 }
725 }
726}
727
728// TestCatfileReader_LargeBlobChunkedRead reads a 64KB blob in 997-byte
729// chunks (a prime number that doesn't align with any power-of-2 buffer)
730// to verify no byte is lost or duplicated across read boundaries.
731func TestCatfileReader_LargeBlobChunkedRead(t *testing.T) {
732 repoDir, blobs := createTestRepo(t)
733 ids := []plumbing.Hash{blobs["large.bin"]}
734
735 cr, err := newCatfileReader(repoDir, ids)
736 if err != nil {
737 t.Fatal(err)
738 }
739 defer cr.Close()
740
741 size, _, _ := cr.Next()
742 if size != 64*1024 {
743 t.Fatalf("size = %d", size)
744 }
745
746 var result bytes.Buffer
747 buf := make([]byte, 997) // prime-sized chunks
748 for {
749 n, err := cr.Read(buf)
750 if n > 0 {
751 result.Write(buf[:n])
752 }
753 if err == io.EOF {
754 break
755 }
756 if err != nil {
757 t.Fatal(err)
758 }
759 }
760
761 if result.Len() != size {
762 t.Fatalf("total read = %d, want %d", result.Len(), size)
763 }
764
765 // Cross-check with git.
766 expected, _ := exec.Command("git", "-C", repoDir, "cat-file", "-p", blobs["large.bin"].String()).Output()
767 if !bytes.Equal(result.Bytes(), expected) {
768 t.Error("chunked read content differs from git cat-file -p output")
769 }
770}
771
772// --- Duplicate SHA test ---
773
774// TestCatfileReader_DuplicateSHAs verifies that requesting the same
775// SHA multiple times works — git cat-file --batch outputs the object
776// for each request independently.
777func TestCatfileReader_DuplicateSHAs(t *testing.T) {
778 repoDir, blobs := createTestRepo(t)
779
780 sha := blobs["hello.txt"]
781 ids := []plumbing.Hash{sha, sha, sha}
782
783 cr, err := newCatfileReader(repoDir, ids)
784 if err != nil {
785 t.Fatal(err)
786 }
787 defer cr.Close()
788
789 for i := 0; i < 3; i++ {
790 size, missing, err := cr.Next()
791 if err != nil {
792 t.Fatalf("Next #%d: %v", i, err)
793 }
794 if missing {
795 t.Fatalf("#%d unexpectedly missing", i)
796 }
797 if size != 12 {
798 t.Fatalf("#%d size = %d, want 12", i, size)
799 }
800 content := make([]byte, size)
801 if _, err := io.ReadFull(cr, content); err != nil {
802 t.Fatal(err)
803 }
804 if string(content) != "hello world\n" {
805 t.Errorf("#%d content = %q", i, content)
806 }
807 }
808
809 _, _, err = cr.Next()
810 if err != io.EOF {
811 t.Fatalf("expected EOF, got %v", err)
812 }
813}