v0.1.1 de06cbd

conformance_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close