1// SPDX-License-Identifier: MIT
2
3package git
4
5import (
6 "bufio"
7 "bytes"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "strconv"
13 "strings"
14 "testing"
15)
16
17// mixedFixture builds a history with all the kinds of content of the plan,
18// leaving everything loose.
19func mixedFixture(t *testing.T, f *fx) {
20 t.Helper()
21 f.write("README.md", []byte("# hello\n"), 0o644)
22 f.write("bin/tool.sh", []byte("#!/bin/sh\necho hi\n"), 0o755)
23 f.write("data/blob.bin", append([]byte{0, 1, 2, 0xff, 0xfe, 0}, bytes.Repeat([]byte{0xde, 0xad}, 4000)...), 0o644)
24 f.write("big.txt", textLines(60000, 0), 0o644) // ~4 MB
25 f.write("src/a/b/c.go", []byte("package c\n"), 0o644)
26 f.write("src/a/ünï.txt", []byte("unicode name\n"), 0o644)
27 if err := os.Symlink("README.md", f.work+"/link"); err != nil {
28 t.Fatal(err)
29 }
30 f.commit("first")
31 // A submodule entry (gitlink): a nested repository.
32 sub := f.work + "/vendor/sub"
33 if err := os.MkdirAll(sub, 0o755); err != nil {
34 t.Fatal(err)
35 }
36 runGit(t, sub, nil, nil, "init", "-q")
37 if err := os.WriteFile(sub+"/x", []byte("x"), 0o644); err != nil {
38 t.Fatal(err)
39 }
40 runGit(t, sub, nil, f.tick(), "add", "x")
41 runGit(t, sub, nil, f.tick(), "commit", "-q", "-m", "sub")
42 f.commit("add submodule\n\nWith a body.\n")
43 f.write("big.txt", textLines(60000, 1), 0o644)
44 f.write("README.md", []byte("# hello\nmore\n"), 0o644)
45 f.commit("")
46 f.git("tag", "light")
47 f.git("tag", "-a", "-m", "annotated tag\n\nbody", "annotated")
48 f.git("tag", "-a", "-m", "tag of a tag", "nested1", "annotated")
49 f.git("tag", "-a", "-m", "tag of a tag of a tag", "nested2", "nested1")
50 f.git("tag", "-a", "-m", "tag of a tree", "treetag", "HEAD^{tree}")
51 f.git("tag", "-a", "-m", "tag of a blob", "blobtag", "HEAD:README.md")
52 f.git("checkout", "-q", "-b", "feature/x")
53 f.write("feature.txt", []byte("feature\n"), 0o644)
54 f.commit("feature work")
55 f.git("checkout", "-q", "main")
56 f.write("big.txt", textLines(60000, 2), 0o644)
57 f.commit("main moves")
58 f.git("merge", "-q", "--no-ff", "-m", "merge feature", "feature/x")
59
60 // Non-UTF-8 message (latin-1) and an extra header (gpgsig, multi-line).
61 latin := []byte("caf\xe9 \xff\xfe message\n")
62 tree := f.git("rev-parse", "HEAD^{tree}")
63 parent := f.git("rev-parse", "HEAD")
64 raw := "tree " + tree + "\nparent " + parent + "\nauthor Zoë <z@example.com> 1700100000 +0530\ncommitter Zoë <z@example.com> 1700100001 -0800\nencoding ISO-8859-1\n" +
65 "gpgsig -----BEGIN PGP SIGNATURE-----\n \n abc\n -----END PGP SIGNATURE-----\n\n" + string(latin)
66 id := f.gitIn([]byte(raw), "hash-object", "-t", "commit", "-w", "--stdin")
67 f.git("update-ref", "refs/heads/latin1", id)
68 f.git("symbolic-ref", "refs/heads/alias", "refs/heads/latin1")
69}
70
71// compareObjects checks every object of the repository against
72// git cat-file --batch.
73func compareObjects(t *testing.T, r *Repo, dir string) {
74 t.Helper()
75 all := runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "cat-file", "--batch-all-objects", "--batch")
76 br := bufio.NewReader(bytes.NewReader(all))
77 count := 0
78 var buf []byte
79 for {
80 hdr, err := br.ReadString('\n')
81 if errors.Is(err, io.EOF) {
82 break
83 }
84 if err != nil {
85 t.Fatal(err)
86 }
87 f := strings.Fields(hdr)
88 if len(f) != 3 {
89 t.Fatalf("bad batch header %q", hdr)
90 }
91 size, _ := strconv.Atoi(f[2])
92 want := make([]byte, size+1)
93 if _, err := io.ReadFull(br, want); err != nil {
94 t.Fatal(err)
95 }
96 want = want[:size]
97 id := mustID(t, f[0])
98 typ, got, err := r.ReadObject(id, buf)
99 if err != nil {
100 t.Fatalf("ReadObject(%s): %v", id, err)
101 }
102 buf = got
103 if typ.String() != f[1] || !bytes.Equal(got, want) {
104 t.Fatalf("ReadObject(%s): type %s len %d, want %s len %d", id, typ, len(got), f[1], size)
105 }
106 htyp, hsize, err := r.ObjectHeader(id)
107 if err != nil || htyp != typ || hsize != int64(size) {
108 t.Fatalf("ObjectHeader(%s) = %s %d %v, want %s %d", id, htyp, hsize, err, f[1], size)
109 }
110 if typ == TypeBlob {
111 bsize, rc, err := r.OpenBlob(id)
112 if err != nil {
113 t.Fatalf("OpenBlob(%s): %v", id, err)
114 }
115 data, err := io.ReadAll(rc)
116 _ = rc.Close()
117 if err != nil || bsize != int64(size) || !bytes.Equal(data, want) {
118 t.Fatalf("OpenBlob(%s): size %d len %d err %v, want %d", id, bsize, len(data), err, size)
119 }
120 }
121 count++
122 }
123 if count == 0 {
124 t.Fatal("no object compared")
125 }
126 t.Logf("%d objects match git cat-file", count)
127}
128
129func compareRefs(t *testing.T, r *Repo, dir string) {
130 t.Helper()
131 out := runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "for-each-ref", "--format=%(refname) %(objectname) %(*objectname) %(*objecttype)")
132 want := lines(string(out))
133 var got []string
134 for ref, err := range r.Refs("") {
135 if err != nil {
136 t.Fatal(err)
137 }
138 peeled := ""
139 if typ, _, err := r.ObjectHeader(ref.ID); err != nil {
140 t.Fatal(err)
141 } else if typ == TypeTag {
142 pid, ptyp, err := r.Peel(ref.ID)
143 if err != nil {
144 t.Fatal(err)
145 }
146 peeled = pid.String() + " " + ptyp.String()
147 }
148 got = append(got, strings.TrimRight(ref.Name+" "+ref.ID.String()+" "+peeled, " "))
149 }
150 for i := range want {
151 want[i] = strings.TrimRight(want[i], " ")
152 }
153 if strings.Join(got, "\n") != strings.Join(want, "\n") {
154 t.Fatalf("refs differ\n got:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
155 }
156 // Prefix filters and PeelRef.
157 for _, prefix := range []string{"refs/heads/", "refs/tags/", "refs/heads/feature/", "refs/"} {
158 wantP := lines(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "for-each-ref", "--format=%(refname)", prefix)))
159 var gotP []string
160 for ref, err := range r.Refs(prefix) {
161 if err != nil {
162 t.Fatal(err)
163 }
164 gotP = append(gotP, ref.Name)
165 }
166 if strings.Join(gotP, ",") != strings.Join(wantP, ",") {
167 t.Errorf("Refs(%q) = %v, want %v", prefix, gotP, wantP)
168 }
169 }
170 for ref, err := range r.Refs("refs/") {
171 if err != nil {
172 t.Fatal(err)
173 }
174 wantC, gerr := gitOutput(t, dir, r.Dir(), "rev-parse", "--verify", "-q", ref.Name+"^{commit}")
175 gotC, err := r.PeelRef(ref)
176 if gerr != nil {
177 if err == nil {
178 t.Errorf("PeelRef(%s) = %s, git finds no commit", ref.Name, gotC)
179 }
180 continue
181 }
182 if err != nil || gotC.String() != wantC {
183 t.Errorf("PeelRef(%s) = %s, %v; want %s", ref.Name, gotC, err, wantC)
184 }
185 viaObj, err := r.PeelToCommit(ref.ID)
186 if err != nil || viaObj.String() != wantC {
187 t.Errorf("PeelToCommit(%s) = %s, %v; want %s", ref.Name, viaObj, err, wantC)
188 }
189 }
190}
191
192func gitOutput(t *testing.T, dir, gitDir string, args ...string) (string, error) {
193 t.Helper()
194 cmd := execCommand(dir, gitDir, args...)
195 out, err := cmd.Output()
196 return strings.TrimSpace(string(out)), err
197}
198
199func compareHead(t *testing.T, r *Repo, dir string) {
200 t.Helper()
201 target, id, err := r.Head()
202 if err != nil {
203 t.Fatal(err)
204 }
205 wantTarget, serr := gitOutput(t, dir, r.Dir(), "symbolic-ref", "-q", "HEAD")
206 if serr != nil {
207 wantTarget = ""
208 }
209 if target != wantTarget {
210 t.Errorf("Head target = %q, want %q", target, wantTarget)
211 }
212 wantID, verr := gitOutput(t, dir, r.Dir(), "rev-parse", "-q", "--verify", "HEAD")
213 if verr != nil {
214 if !id.IsZero() {
215 t.Errorf("unborn HEAD id = %s, want zero", id)
216 }
217 return
218 }
219 if id.String() != wantID {
220 t.Errorf("Head id = %s, want %s", id, wantID)
221 }
222}
223
224func compareTrees(t *testing.T, r *Repo, dir string) {
225 t.Helper()
226 revs := lines(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "rev-list", "--all")))
227 if len(revs) == 0 {
228 return
229 }
230 checked := 0
231 for _, rev := range revs {
232 id := mustID(t, rev)
233 var c Commit
234 if err := r.ReadCommit(id, &c); err != nil {
235 t.Fatal(err)
236 }
237 out := runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "ls-tree", "-r", "-t", "-l", "-z", "--full-tree", rev)
238 var want []string
239 for _, rec := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
240 if rec != "" {
241 want = append(want, rec)
242 }
243 }
244 var got []string
245 var rec func(tree ObjectID, prefix string)
246 rec = func(tree ObjectID, prefix string) {
247 type ent struct {
248 e Entry
249 name string
250 }
251 var ents []ent
252 if err := r.ReadTree(tree, func(e Entry) bool {
253 ents = append(ents, ent{Entry{Name: nil, Mode: e.Mode, ID: e.ID}, prefix + string(e.Name)})
254 return true
255 }); err != nil {
256 t.Fatal(err)
257 }
258 for _, en := range ents {
259 size := "-"
260 if en.e.Mode.Type() == TypeBlob {
261 _, n, err := r.ObjectHeader(en.e.ID)
262 if err != nil {
263 t.Fatalf("size of %s: %v", en.name, err)
264 }
265 size = strconv.FormatInt(n, 10)
266 }
267 got = append(got, fmt.Sprintf("%s %s %s %7s\t%s", en.e.Mode, en.e.Mode.Type(), en.e.ID, size, en.name))
268 if en.e.Mode.IsTree() {
269 rec(en.e.ID, en.name+"/")
270 }
271 }
272 }
273 rec(c.Tree, "")
274 if strings.Join(got, "\n") != strings.Join(want, "\n") {
275 t.Fatalf("tree of %s differs\n got:\n%s\nwant:\n%s", rev, strings.Join(got, "\n"), strings.Join(want, "\n"))
276 }
277 checked++
278 }
279 t.Logf("%d trees match git ls-tree -l", checked)
280}
281
282func compareCommits(t *testing.T, r *Repo, dir string) {
283 t.Helper()
284 revs := lines(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "rev-list", "--all")))
285 var c Commit
286 for _, rev := range revs {
287 id := mustID(t, rev)
288 if err := r.ReadCommit(id, &c); err != nil {
289 t.Fatal(err)
290 }
291 raw := runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "cat-file", "commit", rev)
292 if c.Tree.String() != strings.TrimSpace(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "rev-parse", rev+"^{tree}"))) {
293 t.Errorf("%s: tree %s", rev, c.Tree)
294 }
295 wantParents := lines(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "rev-list", "--parents", "-n", "1", rev)))
296 wp := strings.Fields(wantParents[0])[1:]
297 var gp []string
298 for _, p := range c.Parents {
299 gp = append(gp, p.String())
300 }
301 if strings.Join(gp, " ") != strings.Join(wp, " ") {
302 t.Errorf("%s: parents %v, want %v", rev, gp, wp)
303 }
304 // Author and committer against the raw header lines.
305 for _, h := range []struct {
306 key string
307 s Signature
308 }{{"author ", c.Author}, {"committer ", c.Committer}} {
309 var line string
310 for _, l := range strings.Split(string(raw), "\n") {
311 if strings.HasPrefix(l, h.key) {
312 line = l[len(h.key):]
313 break
314 }
315 }
316 sign := "+"
317 off := h.s.Offset
318 if off < 0 {
319 sign, off = "-", -off
320 }
321 wantLine := fmt.Sprintf("%s <%s> %d %s%02d%02d", h.s.Name, h.s.Email, h.s.Time, sign, off/3600, off%3600/60)
322 if line != wantLine {
323 t.Errorf("%s: %s%q, want %q", rev, h.key, wantLine, line)
324 }
325 }
326 _, body, _ := bytes.Cut(raw, []byte("\n\n"))
327 if !bytes.Equal(c.Message, body) && (len(body) != 0 || len(c.Message) != 0) {
328 t.Errorf("%s: message %q, want %q", rev, c.Message, body)
329 }
330 }
331}
332
333func compareRevParse(t *testing.T, r *Repo, dir string) {
334 t.Helper()
335 names := []string{"HEAD", "main", "feature/x", "light", "annotated", "nested1", "nested2", "latin1", "alias",
336 "refs/heads/main", "refs/tags/light", "heads/main", "tags/annotated", "nosuchref", "config", "../x"}
337 for _, n := range names {
338 want, werr := gitOutput(t, dir, r.Dir(), "rev-parse", "--verify", "-q", n)
339 got, err := r.Resolve(n)
340 if werr != nil {
341 if err == nil {
342 t.Errorf("Resolve(%q) = %s, git fails", n, got)
343 } else if !errors.Is(err, ErrNotFound) {
344 t.Errorf("Resolve(%q): %v, want ErrNotFound", n, err)
345 }
346 continue
347 }
348 if err != nil || got.String() != want {
349 t.Errorf("Resolve(%q) = %s, %v; want %s", n, got, err, want)
350 }
351 wantC, cerr := gitOutput(t, dir, r.Dir(), "rev-parse", "--verify", "-q", n+"^{commit}")
352 gotC, err := r.PeelToCommit(got)
353 if cerr != nil {
354 if err == nil {
355 t.Errorf("PeelToCommit(%q) = %s, git fails", n, gotC)
356 }
357 } else if err != nil || gotC.String() != wantC {
358 t.Errorf("PeelToCommit(%q) = %s, %v; want %s", n, gotC, err, wantC)
359 }
360 }
361 // Abbreviated ids: every length from 4 up, for the commits.
362 for _, rev := range lines(string(runGit(t, dir, nil, []string{"GIT_DIR=" + r.Dir()}, "rev-list", "--all"))) {
363 for _, n := range []int{4, 7, 12, 39, 40} {
364 got, err := r.ResolveCommitPrefix(rev[:n])
365 if err != nil || got.String() != rev {
366 t.Errorf("ResolveCommitPrefix(%s) = %s, %v", rev[:n], got, err)
367 }
368 got, err = r.ResolvePrefix(strings.ToUpper(rev[:n]))
369 if err != nil && !errors.Is(err, ErrAmbiguous) {
370 t.Errorf("ResolvePrefix(%s): %v", strings.ToUpper(rev[:n]), err)
371 } else if err == nil && got.String() != rev {
372 t.Errorf("ResolvePrefix(%s) = %s", rev[:n], got)
373 }
374 }
375 }
376 if _, err := r.ResolvePrefix("zzzz"); !errors.Is(err, ErrInvalidID) {
377 t.Errorf("ResolvePrefix(zzzz): %v, want ErrInvalidID", err)
378 }
379 if _, err := r.ResolvePrefix("abc"); !errors.Is(err, ErrInvalidID) {
380 t.Errorf("ResolvePrefix(abc): %v, want ErrInvalidID", err)
381 }
382 if _, err := r.ResolvePrefix("0000"); !errors.Is(err, ErrNotFound) {
383 t.Errorf("ResolvePrefix(0000): %v, want ErrNotFound", err)
384 }
385}
386
387func checkAll(t *testing.T, r *Repo, dir string) {
388 t.Helper()
389 compareObjects(t, r, dir)
390 compareRefs(t, r, dir)
391 compareHead(t, r, dir)
392 compareTrees(t, r, dir)
393 compareCommits(t, r, dir)
394 compareRevParse(t, r, dir)
395}
396
397func TestConformanceLoose(t *testing.T) {
398 f := newFx(t)
399 mixedFixture(t, f)
400 r := f.open()
401 checkAll(t, r, f.root())
402}
403
404func TestConformancePacked(t *testing.T) {
405 f := newFx(t)
406 mixedFixture(t, f)
407 f.git("repack", "-adf", "--depth=50", "--window=250")
408 f.git("pack-refs", "--all")
409 r := f.open()
410 checkAll(t, r, f.root())
411}
412
413func TestConformanceLoosePlusPackedRefsOverride(t *testing.T) {
414 f := newFx(t)
415 mixedFixture(t, f)
416 f.git("repack", "-ad")
417 f.git("pack-refs", "--all") // everything packed, loose refs removed
418 // New loose objects and a loose ref overriding a packed one.
419 f.write("late.txt", []byte("late\n"), 0o644)
420 late := f.commit("late commit")
421 f.git("update-ref", "refs/heads/feature/x", late)
422 f.git("tag", "late-tag")
423 f.git("branch", "-D", "alias") // deleted: must disappear although packed
424 r := f.open()
425 checkAll(t, r, f.root())
426 id, err := r.ResolveRef("refs/heads/feature/x")
427 if err != nil || id.String() != late {
428 t.Fatalf("loose must override packed: %s %v", id, err)
429 }
430}
431
432func TestConformanceDeepDeltas(t *testing.T) {
433 f := newFx(t)
434 f.write("big.txt", textLines(3000, 0), 0o644)
435 for i := 1; i <= 120; i++ {
436 d := textLines(3000, 0)
437 d = append(d, []byte(fmt.Sprintf("extra %d\n", i))...)
438 d = bytes.Replace(d, []byte("line 5 "), []byte(fmt.Sprintf("line five rev %d ", i)), 1)
439 f.write("big.txt", d, 0o644)
440 f.write("small/f.txt", []byte(strings.Repeat("abc\n", i)), 0o644)
441 f.commit("rev " + strconv.Itoa(i))
442 }
443 f.git("repack", "-adf", "--depth=50", "--window=250")
444 r := f.open()
445 checkAll(t, r, f.root())
446 // Make sure the fixture really has long delta chains.
447 out := f.git("verify-pack", "-v", firstPackIdx(t, f))
448 if !strings.Contains(out, "chain length = 50") && !strings.Contains(out, "chain length = 4") {
449 t.Logf("chain lengths:\n%s", out[max(0, len(out)-300):])
450 }
451}
452
453func firstPackIdx(t *testing.T, f *fx) string {
454 t.Helper()
455 ents, err := os.ReadDir(f.dir + "/objects/pack")
456 if err != nil {
457 t.Fatal(err)
458 }
459 for _, e := range ents {
460 if strings.HasSuffix(e.Name(), ".idx") {
461 return f.dir + "/objects/pack/" + e.Name()
462 }
463 }
464 t.Fatal("no pack")
465 return ""
466}
467
468func TestConformanceRefDelta(t *testing.T) {
469 f := newFx(t)
470 f.write("big.txt", textLines(2000, 0), 0o644)
471 for i := 1; i <= 30; i++ {
472 d := append(textLines(2000, 0), []byte(fmt.Sprintf("extra %d\n", i))...)
473 f.write("big.txt", d, 0o644)
474 f.commit("rev " + strconv.Itoa(i))
475 }
476 f.git("-c", "repack.useDeltaBaseOffset=false", "repack", "-adf", "--depth=50", "--window=250")
477 // Verify the pack really has ref-deltas: entry type 7.
478 pack := strings.TrimSuffix(firstPackIdx(t, f), ".idx") + ".pack"
479 data, err := os.ReadFile(pack)
480 if err != nil {
481 t.Fatal(err)
482 }
483 r := f.open()
484 refDeltas := 0
485 for _, p := range r.snap.Load().packs {
486 for i := uint32(0); i < p.idx.n; i++ {
487 off, _ := p.idx.offset(i)
488 h, err := parseEntryHeader(data, off)
489 if err != nil {
490 t.Fatal(err)
491 }
492 if h.typ == objRefDelta {
493 refDeltas++
494 }
495 }
496 }
497 if refDeltas == 0 {
498 t.Fatal("fixture has no ref-delta")
499 }
500 t.Logf("%d ref-deltas", refDeltas)
501 checkAll(t, r, f.root())
502}
503
504func TestConformanceBareLooseAndPushedPack(t *testing.T) {
505 src := newFx(t)
506 mixedFixture(t, src)
507 bare := newBareFx(t)
508 bare.git("fetch", "-q", src.dir, "+refs/*:refs/*")
509 r := bare.open()
510 checkAll(t, r, bare.root())
511}
512
513func TestConformanceUnbornHead(t *testing.T) {
514 f := newBareFx(t)
515 r := f.open()
516 target, id, err := r.Head()
517 if err != nil || target != "refs/heads/main" || !id.IsZero() {
518 t.Fatalf("Head = %q %s %v", target, id, err)
519 }
520 for ref, err := range r.Refs("") {
521 t.Fatalf("unexpected ref %v %v", ref, err)
522 }
523 if _, err := r.ResolveRef("HEAD"); !errors.Is(err, ErrNotFound) {
524 t.Fatalf("ResolveRef(HEAD) = %v, want ErrNotFound", err)
525 }
526 compareHead(t, r, f.root())
527}
528
529func TestDetachedHead(t *testing.T) {
530 f := newFx(t)
531 f.write("a", []byte("a"), 0o644)
532 c := f.commit("one")
533 f.git("checkout", "-q", "--detach")
534 r := f.open()
535 target, id, err := r.Head()
536 if err != nil || target != "" || id.String() != c {
537 t.Fatalf("Head = %q %s %v", target, id, err)
538 }
539 compareHead(t, r, f.root())
540}
541
542func TestAmbiguousPrefixes(t *testing.T) {
543 for _, layout := range []string{"loose", "packed", "packed-and-loose"} {
544 t.Run(layout, func(t *testing.T) {
545 f := newFx(t)
546 f.write("a", []byte("a"), 0o644)
547 f.commit("one")
548 da, db := ambiguousPair(t)
549 a := f.writeBlob(da)
550 if layout != "loose" {
551 // -k packs the unreferenced loose blob.
552 f.git("repack", "-adkq")
553 f.git("prune-packed")
554 }
555 var b ObjectID
556 if layout == "packed" {
557 b = f.writeBlob(db)
558 f.git("repack", "-adkq")
559 f.git("prune-packed")
560 } else {
561 b = f.writeBlob(db)
562 }
563 r := f.open()
564 pre := a.String()[:4]
565 if _, err := r.ResolvePrefix(pre); !errors.Is(err, ErrAmbiguous) {
566 t.Fatalf("ResolvePrefix(%s) = %v, want ErrAmbiguous", pre, err)
567 }
568 if out, err := f.gitErr("rev-parse", "--verify", "-q", pre); err == nil {
569 t.Fatalf("git does not find the prefix ambiguous: %s", out)
570 }
571 // The shortest unambiguous prefix of each blob resolves to it.
572 for _, id := range []ObjectID{a, b} {
573 other := a
574 if id == a {
575 other = b
576 }
577 n := 4
578 for id.String()[:n] == other.String()[:n] {
579 n++
580 }
581 got, err := r.ResolvePrefix(id.String()[:n])
582 if err != nil || got != id {
583 t.Fatalf("ResolvePrefix(%s) = %s, %v; want %s", id.String()[:n], got, err, id)
584 }
585 if want := f.git("rev-parse", id.String()[:n]); want != id.String() {
586 t.Fatalf("git rev-parse %s = %s", id.String()[:n], want)
587 }
588 if _, err := r.ResolvePrefix(id.String()[:n-1]); !errors.Is(err, ErrAmbiguous) {
589 t.Fatalf("one digit less must stay ambiguous: %v", err)
590 }
591 }
592 // Resolve routes abbreviations through the same code.
593 if _, err := r.Resolve(pre); !errors.Is(err, ErrAmbiguous) {
594 t.Fatalf("Resolve(%s) = %v, want ErrAmbiguous", pre, err)
595 }
596 // ResolveCommitPrefix ignores blobs: no commit matches.
597 if _, err := r.ResolveCommitPrefix(pre); !errors.Is(err, ErrNotFound) {
598 t.Fatalf("ResolveCommitPrefix(%s) = %v, want ErrNotFound", pre, err)
599 }
600 })
601 }
602}
603
604func TestOpenAcceptsGitDirAndBare(t *testing.T) {
605 f := newFx(t)
606 f.write("a", []byte("a"), 0o644)
607 c := f.commit("one")
608 for _, p := range []string{f.work, f.dir} {
609 r, err := Open(p)
610 if err != nil {
611 t.Fatal(err)
612 }
613 id, err := r.ResolveRef("refs/heads/main")
614 if err != nil || id.String() != c {
615 t.Fatalf("%s: %s %v", p, id, err)
616 }
617 _ = r.Close()
618 }
619 if _, err := Open(t.TempDir()); !errors.Is(err, ErrNotFound) {
620 t.Fatalf("Open(empty dir) = %v, want ErrNotFound", err)
621 }
622 if _, err := Open(t.TempDir() + "/nope"); !errors.Is(err, ErrNotFound) {
623 t.Fatalf("Open(missing) = %v, want ErrNotFound", err)
624 }
625}
626
627func TestBlobStreaming(t *testing.T) {
628 f := newFx(t)
629 big := textLines(200000, 3) // ~13 MB, compressible
630 f.write("big", big, 0o644)
631 f.write("empty", nil, 0o644)
632 f.commit("big")
633 for _, packed := range []bool{false, true} {
634 if packed {
635 f.git("repack", "-adf")
636 }
637 r := f.open(WithMaxObjectSize(1 << 20)) // smaller than the blob: it must stream
638 id := mustID(t, f.git("rev-parse", "HEAD:big"))
639 size, rc, err := r.OpenBlob(id)
640 if err != nil {
641 t.Fatal(err)
642 }
643 if size != int64(len(big)) {
644 t.Fatalf("size = %d, want %d", size, len(big))
645 }
646 got, err := io.ReadAll(rc)
647 if err != nil || !bytes.Equal(got, big) {
648 t.Fatalf("content differs: %d bytes, %v", len(got), err)
649 }
650 _ = rc.Close()
651 _ = rc.Close() // idempotent
652 if _, err := rc.Read(make([]byte, 1)); err == nil {
653 t.Fatal("Read after Close succeeded")
654 }
655 // ReadObject refuses it.
656 if _, _, err := r.ReadObject(id, nil); !errors.Is(err, ErrTooLarge) {
657 t.Fatalf("ReadObject(big) = %v, want ErrTooLarge", err)
658 }
659 // Empty blob.
660 eid := mustID(t, f.git("rev-parse", "HEAD:empty"))
661 size, rc, err = r.OpenBlob(eid)
662 if err != nil || size != 0 {
663 t.Fatalf("empty: %d %v", size, err)
664 }
665 if n, err := io.Copy(io.Discard, rc); n != 0 || err != nil {
666 t.Fatalf("empty read: %d %v", n, err)
667 }
668 _ = rc.Close()
669 // Not a blob and missing.
670 if _, _, err := r.OpenBlob(mustID(t, f.git("rev-parse", "HEAD^{tree}"))); !errors.Is(err, ErrWrongType) {
671 t.Fatalf("OpenBlob(tree) = %v, want ErrWrongType", err)
672 }
673 if _, _, err := r.OpenBlob(ObjectID{1}); !errors.Is(err, ErrNotFound) {
674 t.Fatalf("OpenBlob(missing) = %v, want ErrNotFound", err)
675 }
676 // A reader outlives Close of the Repo.
677 _, rc, err = r.OpenBlob(id)
678 if err != nil {
679 t.Fatal(err)
680 }
681 _ = r.Close()
682 got, err = io.ReadAll(rc)
683 if err != nil || !bytes.Equal(got, big) {
684 t.Fatalf("read after Close: %d bytes, %v", len(got), err)
685 }
686 _ = rc.Close()
687 }
688}
689
690func TestLookupPathAndMissing(t *testing.T) {
691 f := newFx(t)
692 mixedFixture(t, f)
693 r := f.open()
694 root := mustID(t, f.git("rev-parse", "HEAD^{tree}"))
695 for _, p := range []string{"README.md", "src/a/b/c.go", "src/a", "vendor/sub", "link", "bin/tool.sh", "src/a/ünï.txt", ""} {
696 e, err := r.LookupPath(root, p)
697 if err != nil {
698 t.Fatalf("LookupPath(%q): %v", p, err)
699 }
700 if p == "" {
701 continue
702 }
703 if want := f.git("rev-parse", "HEAD:"+p); e.ID.String() != want {
704 t.Errorf("LookupPath(%q) = %s, want %s", p, e.ID, want)
705 }
706 }
707 for _, p := range []string{"nope", "src/nope", "README.md/x", "src//a"} {
708 if _, err := r.LookupPath(root, p); !errors.Is(err, ErrNotFound) {
709 t.Errorf("LookupPath(%q) = %v, want ErrNotFound", p, err)
710 }
711 }
712 if _, err := r.LookupPath(root, "vendor/sub/x"); !errors.Is(err, ErrNotFound) {
713 t.Errorf("through gitlink: %v", err)
714 }
715 if e, _ := r.LookupPath(root, "vendor/sub"); !e.Mode.IsGitlink() {
716 t.Errorf("mode of vendor/sub = %s", e.Mode)
717 }
718 if e, _ := r.LookupPath(root, "link"); !e.Mode.IsSymlink() {
719 t.Errorf("mode of link = %s", e.Mode)
720 }
721 if e, _ := r.LookupPath(root, "bin/tool.sh"); e.Mode != ModeExec {
722 t.Errorf("mode of tool.sh = %s", e.Mode)
723 }
724 if _, _, err := r.ReadObject(ObjectID{9}, nil); !errors.Is(err, ErrNotFound) {
725 t.Errorf("ReadObject(missing) = %v", err)
726 }
727 if _, _, err := r.ObjectHeader(ObjectID{9}); !errors.Is(err, ErrNotFound) {
728 t.Errorf("ObjectHeader(missing) = %v", err)
729 }
730 if err := r.ReadTree(mustID(t, f.git("rev-parse", "HEAD")), func(Entry) bool { return true }); !errors.Is(err, ErrWrongType) {
731 t.Errorf("ReadTree(commit) = %v, want ErrWrongType", err)
732 }
733 var c Commit
734 if err := r.ReadCommit(root, &c); !errors.Is(err, ErrWrongType) {
735 t.Errorf("ReadCommit(tree) = %v, want ErrWrongType", err)
736 }
737}
738
739func TestCommitFields(t *testing.T) {
740 f := newFx(t)
741 mixedFixture(t, f)
742 r := f.open()
743 var c Commit
744 if err := r.ReadCommit(mustID(t, f.git("rev-parse", "latin1")), &c); err != nil {
745 t.Fatal(err)
746 }
747 if string(c.Author.Name) != "Zoë" || string(c.Author.Email) != "z@example.com" || c.Author.Time != 1700100000 || c.Author.Offset != 5*3600+30*60 {
748 t.Errorf("author = %+v", c.Author)
749 }
750 if c.Committer.Offset != -8*3600 || c.Committer.Time != 1700100001 {
751 t.Errorf("committer = %+v", c.Committer)
752 }
753 if string(c.Encoding) != "ISO-8859-1" {
754 t.Errorf("encoding = %q", c.Encoding)
755 }
756 if string(c.Message) != "caf\xe9 \xff\xfe message\n" {
757 t.Errorf("message = %q", c.Message)
758 }
759 if len(c.Parents) != 1 {
760 t.Errorf("parents = %v", c.Parents)
761 }
762 if got := c.Author.Date().Format("2006-01-02T15:04:05-07:00"); got != "2023-11-16T07:30:00+05:30" {
763 t.Errorf("date = %s", got)
764 }
765 // Empty message commit.
766 if err := r.ReadCommit(mustID(t, f.git("rev-parse", "HEAD~2")), &c); err != nil || len(c.Message) != 0 {
767 t.Fatalf("empty message commit: %q %v", c.Message, err)
768 }
769 var tg Tag
770 if err := r.ReadTag(mustID(t, f.git("rev-parse", "annotated")), &tg); err != nil {
771 t.Fatal(err)
772 }
773 if string(tg.Name) != "annotated" || tg.Type != TypeCommit || string(tg.Message) != "annotated tag\n\nbody\n" || string(tg.Tagger.Name) != "Grace Hopper" {
774 t.Errorf("tag = %+v", tg)
775 }
776 if err := r.ReadTag(mustID(t, f.git("rev-parse", "nested1")), &tg); err != nil || tg.Type != TypeTag {
777 t.Errorf("nested tag = %+v %v", tg, err)
778 }
779 // Peel of every tag kind.
780 for name, want := range map[string]Type{"light": TypeCommit, "annotated": TypeCommit, "nested1": TypeCommit, "nested2": TypeCommit, "treetag": TypeTree, "blobtag": TypeBlob} {
781 id, err := r.ResolveRef("refs/tags/" + name)
782 if err != nil {
783 t.Fatal(err)
784 }
785 pid, typ, err := r.Peel(id)
786 if err != nil || typ != want {
787 t.Errorf("Peel(%s) = %s %s %v, want %s", name, pid, typ, err, want)
788 continue
789 }
790 wantID := f.git("rev-parse", "refs/tags/"+name+"^{}")
791 if pid.String() != wantID {
792 t.Errorf("Peel(%s) = %s, want %s", name, pid, wantID)
793 }
794 _, err = r.PeelToCommit(id)
795 if (want == TypeCommit) != (err == nil) {
796 t.Errorf("PeelToCommit(%s) err = %v", name, err)
797 }
798 }
799}
800
801// The delta cache sizes decide which buffers readPacked uses for the
802// intermediate results of a chain: the cache, or the pooled scratch buffers.
803func TestConformanceDeltaCacheSizes(t *testing.T) {
804 f := newFx(t)
805 f.write("big.txt", textLines(3000, 0), 0o644)
806 for i := 1; i <= 80; i++ {
807 d := append(textLines(3000, 0), []byte(fmt.Sprintf("extra %d\n", i))...)
808 f.write("big.txt", d, 0o644)
809 f.commit("rev " + strconv.Itoa(i))
810 }
811 f.git("repack", "-adf", "--depth=50", "--window=250")
812 for _, size := range []int64{0, 4096, 1 << 20, 64 << 20} {
813 t.Run(strconv.FormatInt(size, 10), func(t *testing.T) {
814 r := f.open(WithDeltaCacheSize(size))
815 compareObjects(t, r, f.root())
816 // Twice: the second pass runs against a warm cache.
817 compareObjects(t, r, f.root())
818 })
819 }
820}
821
822// The name of a signature loses all its trailing whitespace, as in git's
823// split_ident_line (%an), not only spaces.
824func TestSignatureNameTrimming(t *testing.T) {
825 f := newFx(t)
826 f.write("f", []byte("x\n"), 0o644)
827 f.commit("one")
828 tree := f.git("rev-parse", "HEAD^{tree}")
829 for _, name := range []string{"A B", "A \t\r ", "A\t", "A\r", "A \t B \t", "\tlead \t", "A\v", "A\f", " "} {
830 raw := "tree " + tree + "\nauthor " + name + "<a@b.c> 1700000000 +0100\ncommitter x <a@b.c> 1700000000 +0100\n\nm\n"
831 id := f.gitIn([]byte(raw), "hash-object", "-t", "commit", "-w", "--literally", "--stdin")
832 want := string(runGit(t, f.root(), nil, f.env(), "log", "-1", "--format=%an", id))
833 want = strings.TrimSuffix(want, "\n")
834 var c Commit
835 if err := f.open().ReadCommit(mustID(t, id), &c); err != nil {
836 t.Fatal(err)
837 }
838 if string(c.Author.Name) != want {
839 t.Errorf("author %q: name %q, git %%an %q", name, c.Author.Name, want)
840 }
841 }
842}