detached eb5a076

test(git): conformance, corruption, fuzz, stress and benchmarks

Fixtures are built with the git binary and compared with cat-file,
for-each-ref, ls-tree and rev-parse: loose, packed, deep deltas, ref
deltas, tags (annotated, nested, lightweight), packed and loose refs,
unborn and detached HEAD, submodule, symlink, executable, large, binary
and non-UTF-8 content, ambiguous prefixes, unsupported layouts.
ERMITE_GIT_BENCH_REPO walks every object of a real repository.

Fuzz targets: pack entry header, delta apply, tree, commit, idx lookup,
packed-refs and config. Stress: Refresh during repack, gc and pack-refs
under -race. Benchmarks report allocations; allocation tests pin
0 allocs per tree entry and per reused commit parse.
commit
eb5a0762e3a43abbb5895db7fae19598531ed210
author
brice · · 2026-10-10 20:35 UTC
parent
4d153b9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +2962 -0
A alloc_test.go +82 -0
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "testing"
9+)
10+
11+func TestAllocTreeIterationPerEntry(t *testing.T) {
12+ var tree []byte
13+ const n = 1000
14+ for i := 0; i < n; i++ {
15+ tree = append(tree, fmt.Sprintf("100644 file-%04d.txt\x00", i)...)
16+ tree = append(tree, bytes.Repeat([]byte{byte(i)}, 20)...)
17+ }
18+ count := 0
19+ cb := func(e Entry) bool { count++; return true }
20+ allocs := testing.AllocsPerRun(50, func() {
21+ if err := ParseTree(tree, cb); err != nil {
22+ t.Fatal(err)
23+ }
24+ })
25+ if allocs != 0 {
26+ t.Fatalf("ParseTree allocates %v per tree of %d entries, want 0", allocs, n)
27+ }
28+}
29+
30+func TestAllocReadTreeAndCommit(t *testing.T) {
31+ if raceEnabled {
32+ t.Skip("sync.Pool drops items under the race detector")
33+ }
34+ f := newFx(t)
35+ for i := 0; i < 200; i++ {
36+ f.write(fmt.Sprintf("dir/file-%03d.txt", i), []byte{byte(i)}, 0o644)
37+ }
38+ c := mustID(t, f.commit("one"))
39+ tree := mustID(t, f.git("rev-parse", "HEAD:dir"))
40+ for _, packed := range []bool{false, true} {
41+ if packed {
42+ f.git("repack", "-adf")
43+ }
44+ r := f.open()
45+ entries := 0
46+ cb := func(e Entry) bool { entries++; return true }
47+ _ = r.ReadTree(tree, cb) // warm up the pools
48+ got := testing.AllocsPerRun(50, func() {
49+ if err := r.ReadTree(tree, cb); err != nil {
50+ t.Fatal(err)
51+ }
52+ })
53+ // A constant cost per tree (file handle, path), not per entry.
54+ if got > 8 {
55+ t.Errorf("packed=%v: ReadTree allocates %v for 200 entries", packed, got)
56+ }
57+ if got/float64(entries/50) > 0.1 {
58+ t.Errorf("packed=%v: %v allocs per tree is not 0 per entry", packed, got)
59+ }
60+ var commit Commit
61+ _ = r.ReadCommit(c, &commit)
62+ got = testing.AllocsPerRun(50, func() {
63+ if err := r.ReadCommit(c, &commit); err != nil {
64+ t.Fatal(err)
65+ }
66+ })
67+ limit := 8.0 // loose: path string, *os.File, inflater state
68+ if packed {
69+ limit = 1 // the snapshot reference only
70+ }
71+ t.Logf("packed=%v: ReadCommit allocs/op = %v", packed, got)
72+ if got > limit {
73+ t.Errorf("packed=%v: ReadCommit allocates %v per call after warm-up, want <= %v", packed, got, limit)
74+ }
75+ }
76+ var commit Commit
77+ raw := []byte("tree " + fmt.Sprintf("%040d", 1) + "\nparent " + fmt.Sprintf("%040d", 2) + "\nauthor A <a@x> 1 +0000\ncommitter B <b@x> 2 +0000\n\nmsg\n")
78+ _ = ParseCommit(raw, &commit)
79+ if got := testing.AllocsPerRun(100, func() { _ = ParseCommit(raw, &commit) }); got != 0 {
80+ t.Errorf("ParseCommit allocates %v on a reused Commit", got)
81+ }
82+}
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "testing"
9+)
10+
11+func TestAllocTreeIterationPerEntry(t *testing.T) {
12+ var tree []byte
13+ const n = 1000
14+ for i := 0; i < n; i++ {
15+ tree = append(tree, fmt.Sprintf("100644 file-%04d.txt\x00", i)...)
16+ tree = append(tree, bytes.Repeat([]byte{byte(i)}, 20)...)
17+ }
18+ count := 0
19+ cb := func(e Entry) bool { count++; return true }
20+ allocs := testing.AllocsPerRun(50, func() {
21+ if err := ParseTree(tree, cb); err != nil {
22+ t.Fatal(err)
23+ }
24+ })
25+ if allocs != 0 {
26+ t.Fatalf("ParseTree allocates %v per tree of %d entries, want 0", allocs, n)
27+ }
28+}
29+
30+func TestAllocReadTreeAndCommit(t *testing.T) {
31+ if raceEnabled {
32+ t.Skip("sync.Pool drops items under the race detector")
33+ }
34+ f := newFx(t)
35+ for i := 0; i < 200; i++ {
36+ f.write(fmt.Sprintf("dir/file-%03d.txt", i), []byte{byte(i)}, 0o644)
37+ }
38+ c := mustID(t, f.commit("one"))
39+ tree := mustID(t, f.git("rev-parse", "HEAD:dir"))
40+ for _, packed := range []bool{false, true} {
41+ if packed {
42+ f.git("repack", "-adf")
43+ }
44+ r := f.open()
45+ entries := 0
46+ cb := func(e Entry) bool { entries++; return true }
47+ _ = r.ReadTree(tree, cb) // warm up the pools
48+ got := testing.AllocsPerRun(50, func() {
49+ if err := r.ReadTree(tree, cb); err != nil {
50+ t.Fatal(err)
51+ }
52+ })
53+ // A constant cost per tree (file handle, path), not per entry.
54+ if got > 8 {
55+ t.Errorf("packed=%v: ReadTree allocates %v for 200 entries", packed, got)
56+ }
57+ if got/float64(entries/50) > 0.1 {
58+ t.Errorf("packed=%v: %v allocs per tree is not 0 per entry", packed, got)
59+ }
60+ var commit Commit
61+ _ = r.ReadCommit(c, &commit)
62+ got = testing.AllocsPerRun(50, func() {
63+ if err := r.ReadCommit(c, &commit); err != nil {
64+ t.Fatal(err)
65+ }
66+ })
67+ limit := 8.0 // loose: path string, *os.File, inflater state
68+ if packed {
69+ limit = 1 // the snapshot reference only
70+ }
71+ t.Logf("packed=%v: ReadCommit allocs/op = %v", packed, got)
72+ if got > limit {
73+ t.Errorf("packed=%v: ReadCommit allocates %v per call after warm-up, want <= %v", packed, got, limit)
74+ }
75+ }
76+ var commit Commit
77+ raw := []byte("tree " + fmt.Sprintf("%040d", 1) + "\nparent " + fmt.Sprintf("%040d", 2) + "\nauthor A <a@x> 1 +0000\ncommitter B <b@x> 2 +0000\n\nmsg\n")
78+ _ = ParseCommit(raw, &commit)
79+ if got := testing.AllocsPerRun(100, func() { _ = ParseCommit(raw, &commit) }); got != 0 {
80+ t.Errorf("ParseCommit allocates %v on a reused Commit", got)
81+ }
82+}
A bench_test.go +494 -0
@@ -0,0 +1,494 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "testing"
16+)
17+
18+// benchFixture is a repository with loose objects, a deltified pack and a
19+// directory of many entries. With ERMITE_GIT_BENCH_REPO it is that
20+// repository instead.
21+type benchFixture struct {
22+ dir string
23+ r *Repo
24+ looseCommit, looseBlob ObjectID
25+ packedCommit, packedBlob ObjectID // not deltified
26+ deltaBlob, deltaTree ObjectID // deltified in the pack
27+ bigTree, headCommit ObjectID
28+ prefix string
29+ batchIDs []ObjectID // objects for the git cat-file comparison
30+ allCommits []ObjectID
31+}
32+
33+var (
34+ benchOnce sync.Once
35+ benchFx *benchFixture
36+)
37+
38+func getBench(b *testing.B) *benchFixture {
39+ b.Helper()
40+ requireGit(b)
41+ benchOnce.Do(func() {
42+ fx := &benchFixture{}
43+ if dir := os.Getenv("ERMITE_GIT_BENCH_REPO"); dir != "" {
44+ fx.dir = dir
45+ fx.fromRepo(b, dir)
46+ } else {
47+ fx.generate(b)
48+ }
49+ benchFx = fx
50+ })
51+ if benchFx == nil {
52+ b.Fatal("benchmark fixture failed")
53+ }
54+ return benchFx
55+}
56+
57+func (fx *benchFixture) fromRepo(b *testing.B, dir string) {
58+ r, err := Open(dir)
59+ if err != nil {
60+ b.Fatal(err)
61+ }
62+ fx.r = r
63+ out := runGit(b, dir, nil, nil, "rev-list", "--all")
64+ for _, l := range lines(string(out)) {
65+ fx.allCommits = append(fx.allCommits, mustID(b, l))
66+ }
67+ fx.headCommit = fx.allCommits[0]
68+ fx.finish(b, dir)
69+}
70+
71+func (fx *benchFixture) generate(b *testing.B) {
72+ dir, err := os.MkdirTemp("", "ermite-git-bench-*")
73+ if err != nil {
74+ b.Fatal(err)
75+ }
76+ fx.dir = dir
77+ env := append(gitEnv(), "GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000")
78+ run := func(args ...string) string {
79+ cmd := exec.Command("git", append([]string{"-c", "gc.auto=0", "-c", "init.defaultBranch=main", "-c", "commit.gpgsign=false"}, args...)...)
80+ cmd.Dir, cmd.Env = dir, env
81+ out, err := cmd.CombinedOutput()
82+ if err != nil {
83+ b.Fatalf("git %v: %v\n%s", args, err, out)
84+ }
85+ return strings.TrimSpace(string(out))
86+ }
87+ run("init", "-q")
88+ for i := 0; i < 400; i++ {
89+ _ = os.WriteFile(dir+"/f.txt", append(textLines(800, 0), []byte(fmt.Sprintf("rev %d\n", i))...), 0o644)
90+ _ = os.MkdirAll(dir+"/many", 0o755)
91+ _ = os.WriteFile(fmt.Sprintf("%s/many/file-%04d.txt", dir, i), []byte(fmt.Sprint(i)), 0o644)
92+ run("add", "-A")
93+ run("commit", "-q", "-m", fmt.Sprintf("commit %d\n\nbody of the message", i))
94+ }
95+ // Some commits stay loose: one more after the repack.
96+ run("repack", "-adf", "--depth=50", "--window=100")
97+ _ = os.WriteFile(dir+"/loose.txt", textLines(300, 5), 0o644)
98+ run("add", "-A")
99+ run("commit", "-q", "-m", "loose commit")
100+ var err2 error
101+ fx.r, err2 = Open(dir + "/.git")
102+ if err2 != nil {
103+ b.Fatal(err2)
104+ }
105+ fx.headCommit = mustID(b, run("rev-parse", "HEAD"))
106+ fx.looseCommit = fx.headCommit
107+ fx.looseBlob = mustID(b, run("rev-parse", "HEAD:loose.txt"))
108+ for _, l := range lines(run("rev-list", "--all")) {
109+ fx.allCommits = append(fx.allCommits, mustID(b, l))
110+ }
111+ fx.bigTree = mustID(b, run("rev-parse", "HEAD:many"))
112+ fx.finish(b, dir+"/.git")
113+}
114+
115+// finish picks one object of each kind from the pack.
116+func (fx *benchFixture) finish(b *testing.B, gitDir string) {
117+ s := fx.r.snap.Load()
118+ for _, p := range s.packs {
119+ for i := uint32(0); i < p.idx.n && (fx.deltaBlob.IsZero() || fx.packedBlob.IsZero() || fx.deltaTree.IsZero() || fx.packedCommit.IsZero()); i++ {
120+ off, _ := p.idx.offset(i)
121+ h, err := parseEntryHeader(p.data, off)
122+ if err != nil {
123+ b.Fatal(err)
124+ }
125+ var id ObjectID
126+ copy(id[:], p.idx.id(i))
127+ switch {
128+ case h.isDelta() && h.size > 200:
129+ typ, _ := p.chainEnd(off, h)
130+ if typ == TypeBlob && fx.deltaBlob.IsZero() {
131+ fx.deltaBlob = id
132+ }
133+ if typ == TypeTree && fx.deltaTree.IsZero() {
134+ fx.deltaTree = id
135+ }
136+ case h.typ == uint8(TypeBlob) && h.size > 200 && fx.packedBlob.IsZero():
137+ fx.packedBlob = id
138+ case h.typ == uint8(TypeCommit) && fx.packedCommit.IsZero():
139+ fx.packedCommit = id
140+ }
141+ }
142+ }
143+ if fx.looseCommit.IsZero() {
144+ fx.looseCommit = fx.packedCommit
145+ }
146+ if fx.looseBlob.IsZero() {
147+ fx.looseBlob = fx.packedBlob
148+ }
149+ if fx.deltaBlob.IsZero() {
150+ fx.deltaBlob = fx.packedBlob
151+ }
152+ if fx.deltaTree.IsZero() {
153+ var c Commit
154+ if err := fx.r.ReadCommit(fx.headCommit, &c); err != nil {
155+ b.Fatal(err)
156+ }
157+ fx.deltaTree = c.Tree
158+ }
159+ if fx.bigTree.IsZero() {
160+ var c Commit
161+ if err := fx.r.ReadCommit(fx.headCommit, &c); err != nil {
162+ b.Fatal(err)
163+ }
164+ fx.bigTree = c.Tree
165+ }
166+ fx.prefix = fx.headCommit.String()[:7]
167+ // The set for the cat-file comparison: the commits, then their trees.
168+ var c Commit
169+ for i, id := range fx.allCommits {
170+ if i >= 2000 {
171+ break
172+ }
173+ fx.batchIDs = append(fx.batchIDs, id)
174+ if err := fx.r.ReadCommit(id, &c); err == nil {
175+ fx.batchIDs = append(fx.batchIDs, c.Tree)
176+ }
177+ }
178+}
179+
180+func BenchmarkReadObject(b *testing.B) {
181+ fx := getBench(b)
182+ for _, c := range []struct {
183+ name string
184+ id ObjectID
185+ }{
186+ {"loose-commit", fx.looseCommit},
187+ {"loose-blob", fx.looseBlob},
188+ {"packed-commit", fx.packedCommit},
189+ {"packed-blob", fx.packedBlob},
190+ {"deltified-blob", fx.deltaBlob},
191+ {"deltified-tree", fx.deltaTree},
192+ } {
193+ b.Run(c.name, func(b *testing.B) {
194+ var buf []byte
195+ b.ReportAllocs()
196+ for b.Loop() {
197+ _, data, err := fx.r.ReadObject(c.id, buf)
198+ if err != nil {
199+ b.Fatal(err)
200+ }
201+ buf = data
202+ }
203+ b.SetBytes(int64(len(buf)))
204+ })
205+ }
206+}
207+
208+func BenchmarkReadObjectParallel(b *testing.B) {
209+ fx := getBench(b)
210+ b.ReportAllocs()
211+ b.RunParallel(func(pb *testing.PB) {
212+ var buf []byte
213+ i := 0
214+ for pb.Next() {
215+ id := fx.batchIDs[i%len(fx.batchIDs)]
216+ i++
217+ _, data, err := fx.r.ReadObject(id, buf)
218+ if err != nil {
219+ b.Fatal(err)
220+ }
221+ buf = data
222+ }
223+ })
224+}
225+
226+func BenchmarkObjectHeader(b *testing.B) {
227+ fx := getBench(b)
228+ for _, c := range []struct {
229+ name string
230+ id ObjectID
231+ }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
232+ b.Run(c.name, func(b *testing.B) {
233+ b.ReportAllocs()
234+ for b.Loop() {
235+ if _, _, err := fx.r.ObjectHeader(c.id); err != nil {
236+ b.Fatal(err)
237+ }
238+ }
239+ })
240+ }
241+}
242+
243+func BenchmarkReadCommit(b *testing.B) {
244+ fx := getBench(b)
245+ for _, c := range []struct {
246+ name string
247+ id ObjectID
248+ }{{"loose", fx.looseCommit}, {"packed", fx.packedCommit}} {
249+ b.Run(c.name, func(b *testing.B) {
250+ var commit Commit
251+ if err := fx.r.ReadCommit(c.id, &commit); err != nil { // warm-up
252+ b.Fatal(err)
253+ }
254+ b.ReportAllocs()
255+ for b.Loop() {
256+ if err := fx.r.ReadCommit(c.id, &commit); err != nil {
257+ b.Fatal(err)
258+ }
259+ }
260+ })
261+ }
262+}
263+
264+func BenchmarkParseCommit(b *testing.B) {
265+ raw := []byte("tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nauthor Ada Lovelace <ada@example.com> 1700000000 +0100\ncommitter Grace Hopper <grace@example.com> 1700000001 -0800\n\nmessage\n")
266+ var c Commit
267+ b.ReportAllocs()
268+ b.SetBytes(int64(len(raw)))
269+ for b.Loop() {
270+ if err := ParseCommit(raw, &c); err != nil {
271+ b.Fatal(err)
272+ }
273+ }
274+}
275+
276+func BenchmarkTreeIteration(b *testing.B) {
277+ fx := getBench(b)
278+ for _, c := range []struct {
279+ name string
280+ id ObjectID
281+ }{{"many-entries", fx.bigTree}, {"deltified", fx.deltaTree}} {
282+ b.Run(c.name, func(b *testing.B) {
283+ entries := 0
284+ var sum int
285+ cb := func(e Entry) bool {
286+ entries++
287+ sum += len(e.Name) + int(e.Mode)
288+ return true
289+ }
290+ if err := fx.r.ReadTree(c.id, cb); err != nil { // warm-up
291+ b.Fatal(err)
292+ }
293+ per := entries
294+ b.ReportAllocs()
295+ entries = 0
296+ for b.Loop() {
297+ if err := fx.r.ReadTree(c.id, cb); err != nil {
298+ b.Fatal(err)
299+ }
300+ }
301+ _ = sum
302+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(max(entries, 1)), "ns/entry")
303+ b.ReportMetric(float64(per), "entries/tree")
304+ })
305+ }
306+}
307+
308+func BenchmarkParseTree(b *testing.B) {
309+ var tree []byte
310+ for i := 0; i < 500; i++ {
311+ tree = append(tree, fmt.Sprintf("100644 file-%04d.txt\x00", i)...)
312+ tree = append(tree, bytes.Repeat([]byte{byte(i)}, 20)...)
313+ }
314+ n := 0
315+ cb := func(e Entry) bool { n += len(e.Name); return true }
316+ b.ReportAllocs()
317+ b.SetBytes(int64(len(tree)))
318+ for b.Loop() {
319+ if err := ParseTree(tree, cb); err != nil {
320+ b.Fatal(err)
321+ }
322+ }
323+}
324+
325+func BenchmarkResolvePrefix(b *testing.B) {
326+ fx := getBench(b)
327+ for _, n := range []int{7, 12} {
328+ b.Run(strconv.Itoa(n)+"-digits", func(b *testing.B) {
329+ ids := make([]string, 0, 64)
330+ for i := 0; i < len(fx.allCommits) && i < 64; i++ {
331+ ids = append(ids, fx.allCommits[i].String()[:n])
332+ }
333+ b.ReportAllocs()
334+ i := 0
335+ for b.Loop() {
336+ if _, err := fx.r.ResolvePrefix(ids[i%len(ids)]); err != nil {
337+ b.Fatal(err)
338+ }
339+ i++
340+ }
341+ })
342+ }
343+ b.Run("missing", func(b *testing.B) {
344+ b.ReportAllocs()
345+ for b.Loop() {
346+ if _, err := fx.r.ResolvePrefix("0000000"); err == nil {
347+ b.Fatal("found")
348+ }
349+ }
350+ })
351+}
352+
353+func BenchmarkRefs(b *testing.B) {
354+ fx := getBench(b)
355+ b.Run("all", func(b *testing.B) {
356+ b.ReportAllocs()
357+ for b.Loop() {
358+ n := 0
359+ for _, err := range fx.r.Refs("") {
360+ if err != nil {
361+ b.Fatal(err)
362+ }
363+ n++
364+ }
365+ }
366+ })
367+ b.Run("resolve-head", func(b *testing.B) {
368+ b.ReportAllocs()
369+ for b.Loop() {
370+ if _, err := fx.r.ResolveRef("HEAD"); err != nil {
371+ b.Fatal(err)
372+ }
373+ }
374+ })
375+ b.Run("head", func(b *testing.B) {
376+ b.ReportAllocs()
377+ for b.Loop() {
378+ if _, _, err := fx.r.Head(); err != nil {
379+ b.Fatal(err)
380+ }
381+ }
382+ })
383+ b.Run("resolve-short-name", func(b *testing.B) {
384+ b.ReportAllocs()
385+ for b.Loop() {
386+ if _, err := fx.r.Resolve("main"); err != nil {
387+ _, _ = fx.r.Resolve("master")
388+ }
389+ }
390+ })
391+}
392+
393+func BenchmarkOpenBlob(b *testing.B) {
394+ fx := getBench(b)
395+ for _, c := range []struct {
396+ name string
397+ id ObjectID
398+ }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
399+ b.Run(c.name, func(b *testing.B) {
400+ b.ReportAllocs()
401+ for b.Loop() {
402+ size, rc, err := fx.r.OpenBlob(c.id)
403+ if err != nil {
404+ b.Fatal(err)
405+ }
406+ b.SetBytes(size)
407+ if _, err := io.Copy(io.Discard, rc); err != nil {
408+ b.Fatal(err)
409+ }
410+ _ = rc.Close()
411+ }
412+ })
413+ }
414+}
415+
416+func BenchmarkRefresh(b *testing.B) {
417+ fx := getBench(b)
418+ b.ReportAllocs()
419+ for b.Loop() {
420+ if err := fx.r.Refresh(); err != nil {
421+ b.Fatal(err)
422+ }
423+ }
424+}
425+
426+// BenchmarkGitCatFileBatch is the informal reference: the same objects read
427+// through one long-lived "git cat-file --batch" process, as the gitcli adapter
428+// does. Compare with BenchmarkReadObjectSequence.
429+func BenchmarkGitCatFileBatch(b *testing.B) {
430+ fx := getBench(b)
431+ cmd := exec.Command("git", "cat-file", "--batch")
432+ cmd.Dir = fx.dir
433+ cmd.Env = gitEnv()
434+ stdin, _ := cmd.StdinPipe()
435+ stdout, _ := cmd.StdoutPipe()
436+ if err := cmd.Start(); err != nil {
437+ b.Fatal(err)
438+ }
439+ defer func() { _ = stdin.Close(); _ = cmd.Wait() }()
440+ br := bufio.NewReaderSize(stdout, 1<<20)
441+ var buf []byte
442+ req := make([]byte, 0, 64)
443+ b.ReportAllocs()
444+ i, total := 0, int64(0)
445+ for b.Loop() {
446+ id := fx.batchIDs[i%len(fx.batchIDs)]
447+ i++
448+ req = append(id.AppendHex(req[:0]), '\n')
449+ if _, err := stdin.Write(req); err != nil {
450+ b.Fatal(err)
451+ }
452+ hdr, err := br.ReadSlice('\n')
453+ if err != nil {
454+ b.Fatal(err)
455+ }
456+ f := bytes.Fields(hdr)
457+ size, _ := strconv.Atoi(string(f[2]))
458+ buf = grow(buf, size+1)
459+ if _, err := io.ReadFull(br, buf); err != nil {
460+ b.Fatal(err)
461+ }
462+ total += int64(size)
463+ }
464+ b.SetBytes(total / int64(max(b.N, 1)))
465+}
466+
467+func BenchmarkReadObjectSequence(b *testing.B) {
468+ fx := getBench(b)
469+ var buf []byte
470+ b.ReportAllocs()
471+ i, total := 0, int64(0)
472+ for b.Loop() {
473+ id := fx.batchIDs[i%len(fx.batchIDs)]
474+ i++
475+ _, data, err := fx.r.ReadObject(id, buf)
476+ if err != nil {
477+ b.Fatal(err)
478+ }
479+ buf = data
480+ total += int64(len(data))
481+ }
482+ b.SetBytes(total / int64(max(b.N, 1)))
483+}
484+
485+func TestMain(m *testing.M) {
486+ code := m.Run()
487+ if benchFx != nil && benchFx.r != nil {
488+ _ = benchFx.r.Close()
489+ if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
490+ _ = os.RemoveAll(benchFx.dir)
491+ }
492+ }
493+ os.Exit(code)
494+}
@@ -0,0 +1,494 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "testing"
16+)
17+
18+// benchFixture is a repository with loose objects, a deltified pack and a
19+// directory of many entries. With ERMITE_GIT_BENCH_REPO it is that
20+// repository instead.
21+type benchFixture struct {
22+ dir string
23+ r *Repo
24+ looseCommit, looseBlob ObjectID
25+ packedCommit, packedBlob ObjectID // not deltified
26+ deltaBlob, deltaTree ObjectID // deltified in the pack
27+ bigTree, headCommit ObjectID
28+ prefix string
29+ batchIDs []ObjectID // objects for the git cat-file comparison
30+ allCommits []ObjectID
31+}
32+
33+var (
34+ benchOnce sync.Once
35+ benchFx *benchFixture
36+)
37+
38+func getBench(b *testing.B) *benchFixture {
39+ b.Helper()
40+ requireGit(b)
41+ benchOnce.Do(func() {
42+ fx := &benchFixture{}
43+ if dir := os.Getenv("ERMITE_GIT_BENCH_REPO"); dir != "" {
44+ fx.dir = dir
45+ fx.fromRepo(b, dir)
46+ } else {
47+ fx.generate(b)
48+ }
49+ benchFx = fx
50+ })
51+ if benchFx == nil {
52+ b.Fatal("benchmark fixture failed")
53+ }
54+ return benchFx
55+}
56+
57+func (fx *benchFixture) fromRepo(b *testing.B, dir string) {
58+ r, err := Open(dir)
59+ if err != nil {
60+ b.Fatal(err)
61+ }
62+ fx.r = r
63+ out := runGit(b, dir, nil, nil, "rev-list", "--all")
64+ for _, l := range lines(string(out)) {
65+ fx.allCommits = append(fx.allCommits, mustID(b, l))
66+ }
67+ fx.headCommit = fx.allCommits[0]
68+ fx.finish(b, dir)
69+}
70+
71+func (fx *benchFixture) generate(b *testing.B) {
72+ dir, err := os.MkdirTemp("", "ermite-git-bench-*")
73+ if err != nil {
74+ b.Fatal(err)
75+ }
76+ fx.dir = dir
77+ env := append(gitEnv(), "GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000")
78+ run := func(args ...string) string {
79+ cmd := exec.Command("git", append([]string{"-c", "gc.auto=0", "-c", "init.defaultBranch=main", "-c", "commit.gpgsign=false"}, args...)...)
80+ cmd.Dir, cmd.Env = dir, env
81+ out, err := cmd.CombinedOutput()
82+ if err != nil {
83+ b.Fatalf("git %v: %v\n%s", args, err, out)
84+ }
85+ return strings.TrimSpace(string(out))
86+ }
87+ run("init", "-q")
88+ for i := 0; i < 400; i++ {
89+ _ = os.WriteFile(dir+"/f.txt", append(textLines(800, 0), []byte(fmt.Sprintf("rev %d\n", i))...), 0o644)
90+ _ = os.MkdirAll(dir+"/many", 0o755)
91+ _ = os.WriteFile(fmt.Sprintf("%s/many/file-%04d.txt", dir, i), []byte(fmt.Sprint(i)), 0o644)
92+ run("add", "-A")
93+ run("commit", "-q", "-m", fmt.Sprintf("commit %d\n\nbody of the message", i))
94+ }
95+ // Some commits stay loose: one more after the repack.
96+ run("repack", "-adf", "--depth=50", "--window=100")
97+ _ = os.WriteFile(dir+"/loose.txt", textLines(300, 5), 0o644)
98+ run("add", "-A")
99+ run("commit", "-q", "-m", "loose commit")
100+ var err2 error
101+ fx.r, err2 = Open(dir + "/.git")
102+ if err2 != nil {
103+ b.Fatal(err2)
104+ }
105+ fx.headCommit = mustID(b, run("rev-parse", "HEAD"))
106+ fx.looseCommit = fx.headCommit
107+ fx.looseBlob = mustID(b, run("rev-parse", "HEAD:loose.txt"))
108+ for _, l := range lines(run("rev-list", "--all")) {
109+ fx.allCommits = append(fx.allCommits, mustID(b, l))
110+ }
111+ fx.bigTree = mustID(b, run("rev-parse", "HEAD:many"))
112+ fx.finish(b, dir+"/.git")
113+}
114+
115+// finish picks one object of each kind from the pack.
116+func (fx *benchFixture) finish(b *testing.B, gitDir string) {
117+ s := fx.r.snap.Load()
118+ for _, p := range s.packs {
119+ for i := uint32(0); i < p.idx.n && (fx.deltaBlob.IsZero() || fx.packedBlob.IsZero() || fx.deltaTree.IsZero() || fx.packedCommit.IsZero()); i++ {
120+ off, _ := p.idx.offset(i)
121+ h, err := parseEntryHeader(p.data, off)
122+ if err != nil {
123+ b.Fatal(err)
124+ }
125+ var id ObjectID
126+ copy(id[:], p.idx.id(i))
127+ switch {
128+ case h.isDelta() && h.size > 200:
129+ typ, _ := p.chainEnd(off, h)
130+ if typ == TypeBlob && fx.deltaBlob.IsZero() {
131+ fx.deltaBlob = id
132+ }
133+ if typ == TypeTree && fx.deltaTree.IsZero() {
134+ fx.deltaTree = id
135+ }
136+ case h.typ == uint8(TypeBlob) && h.size > 200 && fx.packedBlob.IsZero():
137+ fx.packedBlob = id
138+ case h.typ == uint8(TypeCommit) && fx.packedCommit.IsZero():
139+ fx.packedCommit = id
140+ }
141+ }
142+ }
143+ if fx.looseCommit.IsZero() {
144+ fx.looseCommit = fx.packedCommit
145+ }
146+ if fx.looseBlob.IsZero() {
147+ fx.looseBlob = fx.packedBlob
148+ }
149+ if fx.deltaBlob.IsZero() {
150+ fx.deltaBlob = fx.packedBlob
151+ }
152+ if fx.deltaTree.IsZero() {
153+ var c Commit
154+ if err := fx.r.ReadCommit(fx.headCommit, &c); err != nil {
155+ b.Fatal(err)
156+ }
157+ fx.deltaTree = c.Tree
158+ }
159+ if fx.bigTree.IsZero() {
160+ var c Commit
161+ if err := fx.r.ReadCommit(fx.headCommit, &c); err != nil {
162+ b.Fatal(err)
163+ }
164+ fx.bigTree = c.Tree
165+ }
166+ fx.prefix = fx.headCommit.String()[:7]
167+ // The set for the cat-file comparison: the commits, then their trees.
168+ var c Commit
169+ for i, id := range fx.allCommits {
170+ if i >= 2000 {
171+ break
172+ }
173+ fx.batchIDs = append(fx.batchIDs, id)
174+ if err := fx.r.ReadCommit(id, &c); err == nil {
175+ fx.batchIDs = append(fx.batchIDs, c.Tree)
176+ }
177+ }
178+}
179+
180+func BenchmarkReadObject(b *testing.B) {
181+ fx := getBench(b)
182+ for _, c := range []struct {
183+ name string
184+ id ObjectID
185+ }{
186+ {"loose-commit", fx.looseCommit},
187+ {"loose-blob", fx.looseBlob},
188+ {"packed-commit", fx.packedCommit},
189+ {"packed-blob", fx.packedBlob},
190+ {"deltified-blob", fx.deltaBlob},
191+ {"deltified-tree", fx.deltaTree},
192+ } {
193+ b.Run(c.name, func(b *testing.B) {
194+ var buf []byte
195+ b.ReportAllocs()
196+ for b.Loop() {
197+ _, data, err := fx.r.ReadObject(c.id, buf)
198+ if err != nil {
199+ b.Fatal(err)
200+ }
201+ buf = data
202+ }
203+ b.SetBytes(int64(len(buf)))
204+ })
205+ }
206+}
207+
208+func BenchmarkReadObjectParallel(b *testing.B) {
209+ fx := getBench(b)
210+ b.ReportAllocs()
211+ b.RunParallel(func(pb *testing.PB) {
212+ var buf []byte
213+ i := 0
214+ for pb.Next() {
215+ id := fx.batchIDs[i%len(fx.batchIDs)]
216+ i++
217+ _, data, err := fx.r.ReadObject(id, buf)
218+ if err != nil {
219+ b.Fatal(err)
220+ }
221+ buf = data
222+ }
223+ })
224+}
225+
226+func BenchmarkObjectHeader(b *testing.B) {
227+ fx := getBench(b)
228+ for _, c := range []struct {
229+ name string
230+ id ObjectID
231+ }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
232+ b.Run(c.name, func(b *testing.B) {
233+ b.ReportAllocs()
234+ for b.Loop() {
235+ if _, _, err := fx.r.ObjectHeader(c.id); err != nil {
236+ b.Fatal(err)
237+ }
238+ }
239+ })
240+ }
241+}
242+
243+func BenchmarkReadCommit(b *testing.B) {
244+ fx := getBench(b)
245+ for _, c := range []struct {
246+ name string
247+ id ObjectID
248+ }{{"loose", fx.looseCommit}, {"packed", fx.packedCommit}} {
249+ b.Run(c.name, func(b *testing.B) {
250+ var commit Commit
251+ if err := fx.r.ReadCommit(c.id, &commit); err != nil { // warm-up
252+ b.Fatal(err)
253+ }
254+ b.ReportAllocs()
255+ for b.Loop() {
256+ if err := fx.r.ReadCommit(c.id, &commit); err != nil {
257+ b.Fatal(err)
258+ }
259+ }
260+ })
261+ }
262+}
263+
264+func BenchmarkParseCommit(b *testing.B) {
265+ raw := []byte("tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nauthor Ada Lovelace <ada@example.com> 1700000000 +0100\ncommitter Grace Hopper <grace@example.com> 1700000001 -0800\n\nmessage\n")
266+ var c Commit
267+ b.ReportAllocs()
268+ b.SetBytes(int64(len(raw)))
269+ for b.Loop() {
270+ if err := ParseCommit(raw, &c); err != nil {
271+ b.Fatal(err)
272+ }
273+ }
274+}
275+
276+func BenchmarkTreeIteration(b *testing.B) {
277+ fx := getBench(b)
278+ for _, c := range []struct {
279+ name string
280+ id ObjectID
281+ }{{"many-entries", fx.bigTree}, {"deltified", fx.deltaTree}} {
282+ b.Run(c.name, func(b *testing.B) {
283+ entries := 0
284+ var sum int
285+ cb := func(e Entry) bool {
286+ entries++
287+ sum += len(e.Name) + int(e.Mode)
288+ return true
289+ }
290+ if err := fx.r.ReadTree(c.id, cb); err != nil { // warm-up
291+ b.Fatal(err)
292+ }
293+ per := entries
294+ b.ReportAllocs()
295+ entries = 0
296+ for b.Loop() {
297+ if err := fx.r.ReadTree(c.id, cb); err != nil {
298+ b.Fatal(err)
299+ }
300+ }
301+ _ = sum
302+ b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(max(entries, 1)), "ns/entry")
303+ b.ReportMetric(float64(per), "entries/tree")
304+ })
305+ }
306+}
307+
308+func BenchmarkParseTree(b *testing.B) {
309+ var tree []byte
310+ for i := 0; i < 500; i++ {
311+ tree = append(tree, fmt.Sprintf("100644 file-%04d.txt\x00", i)...)
312+ tree = append(tree, bytes.Repeat([]byte{byte(i)}, 20)...)
313+ }
314+ n := 0
315+ cb := func(e Entry) bool { n += len(e.Name); return true }
316+ b.ReportAllocs()
317+ b.SetBytes(int64(len(tree)))
318+ for b.Loop() {
319+ if err := ParseTree(tree, cb); err != nil {
320+ b.Fatal(err)
321+ }
322+ }
323+}
324+
325+func BenchmarkResolvePrefix(b *testing.B) {
326+ fx := getBench(b)
327+ for _, n := range []int{7, 12} {
328+ b.Run(strconv.Itoa(n)+"-digits", func(b *testing.B) {
329+ ids := make([]string, 0, 64)
330+ for i := 0; i < len(fx.allCommits) && i < 64; i++ {
331+ ids = append(ids, fx.allCommits[i].String()[:n])
332+ }
333+ b.ReportAllocs()
334+ i := 0
335+ for b.Loop() {
336+ if _, err := fx.r.ResolvePrefix(ids[i%len(ids)]); err != nil {
337+ b.Fatal(err)
338+ }
339+ i++
340+ }
341+ })
342+ }
343+ b.Run("missing", func(b *testing.B) {
344+ b.ReportAllocs()
345+ for b.Loop() {
346+ if _, err := fx.r.ResolvePrefix("0000000"); err == nil {
347+ b.Fatal("found")
348+ }
349+ }
350+ })
351+}
352+
353+func BenchmarkRefs(b *testing.B) {
354+ fx := getBench(b)
355+ b.Run("all", func(b *testing.B) {
356+ b.ReportAllocs()
357+ for b.Loop() {
358+ n := 0
359+ for _, err := range fx.r.Refs("") {
360+ if err != nil {
361+ b.Fatal(err)
362+ }
363+ n++
364+ }
365+ }
366+ })
367+ b.Run("resolve-head", func(b *testing.B) {
368+ b.ReportAllocs()
369+ for b.Loop() {
370+ if _, err := fx.r.ResolveRef("HEAD"); err != nil {
371+ b.Fatal(err)
372+ }
373+ }
374+ })
375+ b.Run("head", func(b *testing.B) {
376+ b.ReportAllocs()
377+ for b.Loop() {
378+ if _, _, err := fx.r.Head(); err != nil {
379+ b.Fatal(err)
380+ }
381+ }
382+ })
383+ b.Run("resolve-short-name", func(b *testing.B) {
384+ b.ReportAllocs()
385+ for b.Loop() {
386+ if _, err := fx.r.Resolve("main"); err != nil {
387+ _, _ = fx.r.Resolve("master")
388+ }
389+ }
390+ })
391+}
392+
393+func BenchmarkOpenBlob(b *testing.B) {
394+ fx := getBench(b)
395+ for _, c := range []struct {
396+ name string
397+ id ObjectID
398+ }{{"loose", fx.looseBlob}, {"packed", fx.packedBlob}, {"deltified", fx.deltaBlob}} {
399+ b.Run(c.name, func(b *testing.B) {
400+ b.ReportAllocs()
401+ for b.Loop() {
402+ size, rc, err := fx.r.OpenBlob(c.id)
403+ if err != nil {
404+ b.Fatal(err)
405+ }
406+ b.SetBytes(size)
407+ if _, err := io.Copy(io.Discard, rc); err != nil {
408+ b.Fatal(err)
409+ }
410+ _ = rc.Close()
411+ }
412+ })
413+ }
414+}
415+
416+func BenchmarkRefresh(b *testing.B) {
417+ fx := getBench(b)
418+ b.ReportAllocs()
419+ for b.Loop() {
420+ if err := fx.r.Refresh(); err != nil {
421+ b.Fatal(err)
422+ }
423+ }
424+}
425+
426+// BenchmarkGitCatFileBatch is the informal reference: the same objects read
427+// through one long-lived "git cat-file --batch" process, as the gitcli adapter
428+// does. Compare with BenchmarkReadObjectSequence.
429+func BenchmarkGitCatFileBatch(b *testing.B) {
430+ fx := getBench(b)
431+ cmd := exec.Command("git", "cat-file", "--batch")
432+ cmd.Dir = fx.dir
433+ cmd.Env = gitEnv()
434+ stdin, _ := cmd.StdinPipe()
435+ stdout, _ := cmd.StdoutPipe()
436+ if err := cmd.Start(); err != nil {
437+ b.Fatal(err)
438+ }
439+ defer func() { _ = stdin.Close(); _ = cmd.Wait() }()
440+ br := bufio.NewReaderSize(stdout, 1<<20)
441+ var buf []byte
442+ req := make([]byte, 0, 64)
443+ b.ReportAllocs()
444+ i, total := 0, int64(0)
445+ for b.Loop() {
446+ id := fx.batchIDs[i%len(fx.batchIDs)]
447+ i++
448+ req = append(id.AppendHex(req[:0]), '\n')
449+ if _, err := stdin.Write(req); err != nil {
450+ b.Fatal(err)
451+ }
452+ hdr, err := br.ReadSlice('\n')
453+ if err != nil {
454+ b.Fatal(err)
455+ }
456+ f := bytes.Fields(hdr)
457+ size, _ := strconv.Atoi(string(f[2]))
458+ buf = grow(buf, size+1)
459+ if _, err := io.ReadFull(br, buf); err != nil {
460+ b.Fatal(err)
461+ }
462+ total += int64(size)
463+ }
464+ b.SetBytes(total / int64(max(b.N, 1)))
465+}
466+
467+func BenchmarkReadObjectSequence(b *testing.B) {
468+ fx := getBench(b)
469+ var buf []byte
470+ b.ReportAllocs()
471+ i, total := 0, int64(0)
472+ for b.Loop() {
473+ id := fx.batchIDs[i%len(fx.batchIDs)]
474+ i++
475+ _, data, err := fx.r.ReadObject(id, buf)
476+ if err != nil {
477+ b.Fatal(err)
478+ }
479+ buf = data
480+ total += int64(len(data))
481+ }
482+ b.SetBytes(total / int64(max(b.N, 1)))
483+}
484+
485+func TestMain(m *testing.M) {
486+ code := m.Run()
487+ if benchFx != nil && benchFx.r != nil {
488+ _ = benchFx.r.Close()
489+ if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
490+ _ = os.RemoveAll(benchFx.dir)
491+ }
492+ }
493+ os.Exit(code)
494+}
A conformance_test.go +820 -0
@@ -0,0 +1,820 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
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.
19+func 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.
73+func 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+
129+func 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+
192+func 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+
199+func 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+
224+func 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+
282+func 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+
333+func 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+
387+func 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+
397+func TestConformanceLoose(t *testing.T) {
398+ f := newFx(t)
399+ mixedFixture(t, f)
400+ r := f.open()
401+ checkAll(t, r, f.root())
402+}
403+
404+func 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+
413+func 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+
432+func 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+
453+func 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+
468+func 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+
504+func 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+
513+func 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+
529+func 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+
542+func 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+
604+func 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+
627+func 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+
690+func 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+
739+func 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.
803+func 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+}
@@ -0,0 +1,820 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
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.
19+func 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.
73+func 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+
129+func 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+
192+func 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+
199+func 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+
224+func 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+
282+func 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+
333+func 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+
387+func 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+
397+func TestConformanceLoose(t *testing.T) {
398+ f := newFx(t)
399+ mixedFixture(t, f)
400+ r := f.open()
401+ checkAll(t, r, f.root())
402+}
403+
404+func 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+
413+func 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+
432+func 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+
453+func 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+
468+func 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+
504+func 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+
513+func 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+
529+func 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+
542+func 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+
604+func 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+
627+func 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+
690+func 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+
739+func 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.
803+func 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+}
A exec_test.go +17 -0
@@ -0,0 +1,17 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import "os/exec"
6+
7+func execCommand(dir, gitDir string, args ...string) *exec.Cmd {
8+ cmd := exec.Command("git", args...)
9+ cmd.Dir = dir
10+ cmd.Env = append(gitEnv(), "GIT_DIR="+gitDir)
11+ return cmd
12+}
13+
14+func (f *fx) gitErr(args ...string) (string, error) {
15+ out, err := execCommand(f.root(), f.dir, args...).Output()
16+ return string(out), err
17+}
@@ -0,0 +1,17 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import "os/exec"
6+
7+func execCommand(dir, gitDir string, args ...string) *exec.Cmd {
8+ cmd := exec.Command("git", args...)
9+ cmd.Dir = dir
10+ cmd.Env = append(gitEnv(), "GIT_DIR="+gitDir)
11+ return cmd
12+}
13+
14+func (f *fx) gitErr(args ...string) (string, error) {
15+ out, err := execCommand(f.root(), f.dir, args...).Output()
16+ return string(out), err
17+}
A fuzz_test.go +170 -0
@@ -0,0 +1,170 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+)
11+
12+func okOrCorrupt(t *testing.T, err error) {
13+ t.Helper()
14+ if err != nil && !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) && !errors.Is(err, ErrUnsupported) {
15+ t.Fatalf("error is not a typed one: %v", err)
16+ }
17+}
18+
19+func FuzzPackEntryHeader(f *testing.F) {
20+ pack := func(body ...byte) []byte {
21+ d := append([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"), body...)
22+ return append(d, make([]byte, 20)...)
23+ }
24+ f.Add(pack(0x35, 0x78, 0x9c, 0x01), uint64(12))
25+ f.Add(pack(0xb4, 0x23, 0x78), uint64(12))
26+ f.Add(pack(0x65, 0x05, 0x78), uint64(12))
27+ f.Add(pack(0x65, 0xff, 0xff, 0xff, 0x7f, 0x78), uint64(12))
28+ f.Add(append(pack(0x75), bytes.Repeat([]byte{7}, 25)...), uint64(12))
29+ f.Add(pack(bytes.Repeat([]byte{0xff}, 30)...), uint64(12))
30+ f.Add([]byte{}, uint64(0))
31+ f.Fuzz(func(t *testing.T, data []byte, off uint64) {
32+ h, err := parseEntryHeader(data, off)
33+ okOrCorrupt(t, err)
34+ if err == nil {
35+ if h.data > uint64(len(data)) || (h.typ == objOfsDelta && h.baseOff >= off) {
36+ t.Fatalf("inconsistent header %+v for %d bytes at %d", h, len(data), off)
37+ }
38+ }
39+ })
40+}
41+
42+func FuzzDeltaApply(f *testing.F) {
43+ base := []byte("hello, delta world")
44+ good := append(varint(uint64(len(base))), varint(12)...)
45+ good = append(good, copyOp(0, 5)...)
46+ good = append(good, 3, ' ', 'G', 'o')
47+ good = append(good, copyOp(11, 4)...)
48+ f.Add(base, good)
49+ f.Add(base, []byte{})
50+ f.Add(base, []byte{0x12, 0x05, 0x80})
51+ f.Add([]byte{}, []byte{0x00, 0x00})
52+ f.Add(base, append(varint(uint64(len(base))), 0xff, 0xff, 0xff, 0xff, 0x0f, 0x90, 0x01))
53+ f.Add(base, append(append(varint(uint64(len(base))), varint(1<<20)...), 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff))
54+ f.Fuzz(func(t *testing.T, base, delta []byte) {
55+ out, err := patch(base, delta, 1<<20)
56+ okOrCorrupt(t, err)
57+ if err == nil {
58+ _, rs, _, herr := deltaHeader(delta)
59+ if herr != nil || uint64(len(out)) != rs {
60+ t.Fatalf("result of %d bytes, header says %d (%v)", len(out), rs, herr)
61+ }
62+ }
63+ })
64+}
65+
66+func FuzzParseTree(f *testing.F) {
67+ f.Add(append([]byte("100644 a\x00"), bytes.Repeat([]byte{1}, 20)...))
68+ f.Add(append(append([]byte("40000 dir\x00"), bytes.Repeat([]byte{2}, 20)...), append([]byte("160000 sub\x00"), bytes.Repeat([]byte{3}, 20)...)...))
69+ f.Add([]byte("100644 \x00"))
70+ f.Add([]byte("100644 name"))
71+ f.Add([]byte{})
72+ f.Add(bytes.Repeat([]byte("1 \x00"), 30))
73+ f.Fuzz(func(t *testing.T, data []byte) {
74+ n := 0
75+ err := ParseTree(data, func(e Entry) bool {
76+ n++
77+ if len(e.Name) == 0 {
78+ t.Fatal("empty name accepted")
79+ }
80+ return true
81+ })
82+ okOrCorrupt(t, err)
83+ if err == nil && n == 0 && len(data) != 0 {
84+ t.Fatal("non-empty tree without entries")
85+ }
86+ })
87+}
88+
89+func FuzzParseCommit(f *testing.F) {
90+ tree, par := strings.Repeat("a", 40), strings.Repeat("b", 40)
91+ f.Add([]byte("tree " + tree + "\nparent " + par + "\nauthor A <a@x> 1700000000 +0100\ncommitter B <b@x> 1700000001 -0830\n\nmessage\n"))
92+ f.Add([]byte("tree " + tree + "\ngpgsig -----BEGIN-----\n \n abc\n -----END-----\nencoding latin1\n\n\xe9\xff"))
93+ f.Add([]byte("tree " + tree))
94+ f.Add([]byte("tree " + tree + "\nauthor <> 99999999999999999999999 +9999\n\n"))
95+ f.Add([]byte("tree\n\n"))
96+ f.Add([]byte{})
97+ f.Add([]byte("\n\n\n"))
98+ var c Commit
99+ f.Fuzz(func(t *testing.T, data []byte) {
100+ err := ParseCommit(data, &c)
101+ okOrCorrupt(t, err)
102+ if err == nil && len(c.Message) > len(data) {
103+ t.Fatal("message longer than input")
104+ }
105+ var tg Tag
106+ okOrCorrupt(t, ParseTag(data, &tg))
107+ _ = parseSignature(data)
108+ })
109+}
110+
111+func FuzzIdxLookup(f *testing.F) {
112+ var offs []uint64
113+ b1, b2 := []byte("one"), []byte("two")
114+ _, idx := buildPack([][]byte{entryBytes(3, 3, nil, b1), entryBytes(3, 3, nil, b2)},
115+ []ObjectID{objectIDOf("blob", b1), objectIDOf("blob", b2)}, &offs)
116+ id := objectIDOf("blob", b1)
117+ f.Add(idx, id[:])
118+ f.Add(idx[:len(idx)-1], id[:])
119+ f.Add(idx[:1100], id[:])
120+ f.Add([]byte{}, id[:])
121+ big := bytes.Clone(idx)
122+ big[len(big)-41] |= 0x80
123+ f.Add(big, id[:])
124+ f.Fuzz(func(t *testing.T, data, key []byte) {
125+ x, err := parseIdx(data)
126+ if err != nil {
127+ okOrCorrupt(t, err)
128+ return
129+ }
130+ var want ObjectID
131+ copy(want[:], key)
132+ if pos, ok := x.find(&want); ok {
133+ if pos >= x.n || !bytes.Equal(x.id(pos), want[:]) {
134+ t.Fatalf("find returned position %d for %s", pos, want)
135+ }
136+ _, err := x.offset(pos)
137+ okOrCorrupt(t, err)
138+ }
139+ if len(key) > 0 {
140+ nib := min(len(key)*2, 40)
141+ x.prefixMatches(key, nib, func(id ObjectID) bool {
142+ if !hasNibblePrefix(id[:], key, nib) {
143+ t.Fatalf("prefixMatches yielded %s for %x/%d", id, key, nib)
144+ }
145+ return true
146+ })
147+ }
148+ for i := uint32(0); i < min(x.n, 8); i++ {
149+ _, err := x.offset(i)
150+ okOrCorrupt(t, err)
151+ }
152+ })
153+}
154+
155+func FuzzPackedRefsAndConfig(f *testing.F) {
156+ a := strings.Repeat("a", 40)
157+ f.Add("# pack-refs with: peeled fully-peeled sorted \n" + a + " refs/heads/main\n" + a + " refs/tags/v1\n^" + a + "\n")
158+ f.Add("^" + a)
159+ f.Add("[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha1\n")
160+ f.Add("[")
161+ f.Add("ref: refs/heads/main\n")
162+ f.Fuzz(func(t *testing.T, s string) {
163+ _, err := parsePackedRefs(s)
164+ okOrCorrupt(t, err)
165+ okOrCorrupt(t, checkConfigData([]byte(s)))
166+ _, err = parseRefContent([]byte(s))
167+ okOrCorrupt(t, err)
168+ _ = validRefName(s)
169+ })
170+}
@@ -0,0 +1,170 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+)
11+
12+func okOrCorrupt(t *testing.T, err error) {
13+ t.Helper()
14+ if err != nil && !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) && !errors.Is(err, ErrUnsupported) {
15+ t.Fatalf("error is not a typed one: %v", err)
16+ }
17+}
18+
19+func FuzzPackEntryHeader(f *testing.F) {
20+ pack := func(body ...byte) []byte {
21+ d := append([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"), body...)
22+ return append(d, make([]byte, 20)...)
23+ }
24+ f.Add(pack(0x35, 0x78, 0x9c, 0x01), uint64(12))
25+ f.Add(pack(0xb4, 0x23, 0x78), uint64(12))
26+ f.Add(pack(0x65, 0x05, 0x78), uint64(12))
27+ f.Add(pack(0x65, 0xff, 0xff, 0xff, 0x7f, 0x78), uint64(12))
28+ f.Add(append(pack(0x75), bytes.Repeat([]byte{7}, 25)...), uint64(12))
29+ f.Add(pack(bytes.Repeat([]byte{0xff}, 30)...), uint64(12))
30+ f.Add([]byte{}, uint64(0))
31+ f.Fuzz(func(t *testing.T, data []byte, off uint64) {
32+ h, err := parseEntryHeader(data, off)
33+ okOrCorrupt(t, err)
34+ if err == nil {
35+ if h.data > uint64(len(data)) || (h.typ == objOfsDelta && h.baseOff >= off) {
36+ t.Fatalf("inconsistent header %+v for %d bytes at %d", h, len(data), off)
37+ }
38+ }
39+ })
40+}
41+
42+func FuzzDeltaApply(f *testing.F) {
43+ base := []byte("hello, delta world")
44+ good := append(varint(uint64(len(base))), varint(12)...)
45+ good = append(good, copyOp(0, 5)...)
46+ good = append(good, 3, ' ', 'G', 'o')
47+ good = append(good, copyOp(11, 4)...)
48+ f.Add(base, good)
49+ f.Add(base, []byte{})
50+ f.Add(base, []byte{0x12, 0x05, 0x80})
51+ f.Add([]byte{}, []byte{0x00, 0x00})
52+ f.Add(base, append(varint(uint64(len(base))), 0xff, 0xff, 0xff, 0xff, 0x0f, 0x90, 0x01))
53+ f.Add(base, append(append(varint(uint64(len(base))), varint(1<<20)...), 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff))
54+ f.Fuzz(func(t *testing.T, base, delta []byte) {
55+ out, err := patch(base, delta, 1<<20)
56+ okOrCorrupt(t, err)
57+ if err == nil {
58+ _, rs, _, herr := deltaHeader(delta)
59+ if herr != nil || uint64(len(out)) != rs {
60+ t.Fatalf("result of %d bytes, header says %d (%v)", len(out), rs, herr)
61+ }
62+ }
63+ })
64+}
65+
66+func FuzzParseTree(f *testing.F) {
67+ f.Add(append([]byte("100644 a\x00"), bytes.Repeat([]byte{1}, 20)...))
68+ f.Add(append(append([]byte("40000 dir\x00"), bytes.Repeat([]byte{2}, 20)...), append([]byte("160000 sub\x00"), bytes.Repeat([]byte{3}, 20)...)...))
69+ f.Add([]byte("100644 \x00"))
70+ f.Add([]byte("100644 name"))
71+ f.Add([]byte{})
72+ f.Add(bytes.Repeat([]byte("1 \x00"), 30))
73+ f.Fuzz(func(t *testing.T, data []byte) {
74+ n := 0
75+ err := ParseTree(data, func(e Entry) bool {
76+ n++
77+ if len(e.Name) == 0 {
78+ t.Fatal("empty name accepted")
79+ }
80+ return true
81+ })
82+ okOrCorrupt(t, err)
83+ if err == nil && n == 0 && len(data) != 0 {
84+ t.Fatal("non-empty tree without entries")
85+ }
86+ })
87+}
88+
89+func FuzzParseCommit(f *testing.F) {
90+ tree, par := strings.Repeat("a", 40), strings.Repeat("b", 40)
91+ f.Add([]byte("tree " + tree + "\nparent " + par + "\nauthor A <a@x> 1700000000 +0100\ncommitter B <b@x> 1700000001 -0830\n\nmessage\n"))
92+ f.Add([]byte("tree " + tree + "\ngpgsig -----BEGIN-----\n \n abc\n -----END-----\nencoding latin1\n\n\xe9\xff"))
93+ f.Add([]byte("tree " + tree))
94+ f.Add([]byte("tree " + tree + "\nauthor <> 99999999999999999999999 +9999\n\n"))
95+ f.Add([]byte("tree\n\n"))
96+ f.Add([]byte{})
97+ f.Add([]byte("\n\n\n"))
98+ var c Commit
99+ f.Fuzz(func(t *testing.T, data []byte) {
100+ err := ParseCommit(data, &c)
101+ okOrCorrupt(t, err)
102+ if err == nil && len(c.Message) > len(data) {
103+ t.Fatal("message longer than input")
104+ }
105+ var tg Tag
106+ okOrCorrupt(t, ParseTag(data, &tg))
107+ _ = parseSignature(data)
108+ })
109+}
110+
111+func FuzzIdxLookup(f *testing.F) {
112+ var offs []uint64
113+ b1, b2 := []byte("one"), []byte("two")
114+ _, idx := buildPack([][]byte{entryBytes(3, 3, nil, b1), entryBytes(3, 3, nil, b2)},
115+ []ObjectID{objectIDOf("blob", b1), objectIDOf("blob", b2)}, &offs)
116+ id := objectIDOf("blob", b1)
117+ f.Add(idx, id[:])
118+ f.Add(idx[:len(idx)-1], id[:])
119+ f.Add(idx[:1100], id[:])
120+ f.Add([]byte{}, id[:])
121+ big := bytes.Clone(idx)
122+ big[len(big)-41] |= 0x80
123+ f.Add(big, id[:])
124+ f.Fuzz(func(t *testing.T, data, key []byte) {
125+ x, err := parseIdx(data)
126+ if err != nil {
127+ okOrCorrupt(t, err)
128+ return
129+ }
130+ var want ObjectID
131+ copy(want[:], key)
132+ if pos, ok := x.find(&want); ok {
133+ if pos >= x.n || !bytes.Equal(x.id(pos), want[:]) {
134+ t.Fatalf("find returned position %d for %s", pos, want)
135+ }
136+ _, err := x.offset(pos)
137+ okOrCorrupt(t, err)
138+ }
139+ if len(key) > 0 {
140+ nib := min(len(key)*2, 40)
141+ x.prefixMatches(key, nib, func(id ObjectID) bool {
142+ if !hasNibblePrefix(id[:], key, nib) {
143+ t.Fatalf("prefixMatches yielded %s for %x/%d", id, key, nib)
144+ }
145+ return true
146+ })
147+ }
148+ for i := uint32(0); i < min(x.n, 8); i++ {
149+ _, err := x.offset(i)
150+ okOrCorrupt(t, err)
151+ }
152+ })
153+}
154+
155+func FuzzPackedRefsAndConfig(f *testing.F) {
156+ a := strings.Repeat("a", 40)
157+ f.Add("# pack-refs with: peeled fully-peeled sorted \n" + a + " refs/heads/main\n" + a + " refs/tags/v1\n^" + a + "\n")
158+ f.Add("^" + a)
159+ f.Add("[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha1\n")
160+ f.Add("[")
161+ f.Add("ref: refs/heads/main\n")
162+ f.Fuzz(func(t *testing.T, s string) {
163+ _, err := parsePackedRefs(s)
164+ okOrCorrupt(t, err)
165+ okOrCorrupt(t, checkConfigData([]byte(s)))
166+ _, err = parseRefContent([]byte(s))
167+ okOrCorrupt(t, err)
168+ _ = validRefName(s)
169+ })
170+}
A helpers_test.go +207 -0
@@ -0,0 +1,207 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "crypto/sha1"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "testing"
15+)
16+
17+// fx builds a fixture repository with the git binary, which is also the
18+// oracle of the tests.
19+type fx struct {
20+ t *testing.T
21+ dir string // the git directory (bare) or work/.git
22+ work string // work tree, "" for a bare repository
23+ n int // commit counter, drives the dates
24+}
25+
26+func gitEnv() []string {
27+ return append(os.Environ(),
28+ "GIT_CONFIG_GLOBAL=/dev/null",
29+ "GIT_CONFIG_SYSTEM=/dev/null",
30+ "GIT_CONFIG_NOSYSTEM=1",
31+ "GIT_TERMINAL_PROMPT=0",
32+ "GIT_AUTHOR_NAME=Ada Lovelace",
33+ "GIT_AUTHOR_EMAIL=ada@example.com",
34+ "GIT_COMMITTER_NAME=Grace Hopper",
35+ "GIT_COMMITTER_EMAIL=grace@example.com",
36+ "LC_ALL=C",
37+ )
38+}
39+
40+func requireGit(t testing.TB) {
41+ t.Helper()
42+ if _, err := exec.LookPath("git"); err != nil {
43+ t.Skip("git binary not available")
44+ }
45+}
46+
47+// runGit runs git in dir and returns its stdout.
48+func runGit(t testing.TB, dir string, stdin []byte, extraEnv []string, args ...string) []byte {
49+ t.Helper()
50+ cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "gc.auto=0", "-c", "init.defaultBranch=main", "-c", "protocol.file.allow=always"}, args...)...)
51+ cmd.Dir = dir
52+ cmd.Env = append(gitEnv(), extraEnv...)
53+ if stdin != nil {
54+ cmd.Stdin = bytes.NewReader(stdin)
55+ }
56+ var out, errb bytes.Buffer
57+ cmd.Stdout, cmd.Stderr = &out, &errb
58+ if err := cmd.Run(); err != nil {
59+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, errb.String())
60+ }
61+ return out.Bytes()
62+}
63+
64+// newFx creates an empty non-bare repository; Open is given work, so the
65+// ".git" directory handling is covered.
66+func newFx(t *testing.T) *fx {
67+ requireGit(t)
68+ work := filepath.Join(t.TempDir(), "work")
69+ if err := os.MkdirAll(work, 0o755); err != nil {
70+ t.Fatal(err)
71+ }
72+ f := &fx{t: t, work: work, dir: filepath.Join(work, ".git")}
73+ f.git("init", "-q")
74+ return f
75+}
76+
77+// newBareFx creates an empty bare repository.
78+func newBareFx(t *testing.T) *fx {
79+ requireGit(t)
80+ dir := filepath.Join(t.TempDir(), "bare.git")
81+ f := &fx{t: t, dir: dir}
82+ runGit(t, t.TempDir(), nil, nil, "init", "-q", "--bare", dir)
83+ return f
84+}
85+
86+func (f *fx) root() string {
87+ if f.work != "" {
88+ return f.work
89+ }
90+ return f.dir
91+}
92+
93+func (f *fx) git(args ...string) string {
94+ f.t.Helper()
95+ return strings.TrimRight(string(runGit(f.t, f.root(), nil, f.env(), args...)), "\n")
96+}
97+
98+func (f *fx) gitIn(stdin []byte, args ...string) string {
99+ f.t.Helper()
100+ return strings.TrimRight(string(runGit(f.t, f.root(), stdin, f.env(), args...)), "\n")
101+}
102+
103+func (f *fx) env() []string {
104+ if f.work != "" {
105+ return nil
106+ }
107+ return []string{"GIT_DIR=" + f.dir}
108+}
109+
110+func (f *fx) tick() []string {
111+ f.n++
112+ d := fmt.Sprintf("%d +0%d00", 1700000000+f.n*3600, f.n%10)
113+ if f.n%3 == 0 {
114+ d = fmt.Sprintf("%d -0%d30", 1700000000+f.n*3600, f.n%9)
115+ }
116+ return []string{"GIT_AUTHOR_DATE=" + d, "GIT_COMMITTER_DATE=" + d}
117+}
118+
119+func (f *fx) write(path string, data []byte, mode os.FileMode) {
120+ f.t.Helper()
121+ full := filepath.Join(f.work, path)
122+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
123+ f.t.Fatal(err)
124+ }
125+ if err := os.WriteFile(full, data, mode); err != nil {
126+ f.t.Fatal(err)
127+ }
128+ if err := os.Chmod(full, mode); err != nil {
129+ f.t.Fatal(err)
130+ }
131+}
132+
133+// commit stages everything and commits with the next date.
134+func (f *fx) commit(msg string) string {
135+ f.t.Helper()
136+ f.git("add", "-A")
137+ runGit(f.t, f.work, nil, f.tick(), "commit", "-q", "--allow-empty", "--allow-empty-message", "-m", msg)
138+ return f.git("rev-parse", "HEAD")
139+}
140+
141+func (f *fx) open(opts ...Option) *Repo {
142+ f.t.Helper()
143+ path := f.dir
144+ if f.work != "" {
145+ path = f.work
146+ }
147+ r, err := Open(path, opts...)
148+ if err != nil {
149+ f.t.Fatalf("Open: %v", err)
150+ }
151+ f.t.Cleanup(func() { _ = r.Close() })
152+ return r
153+}
154+
155+// lines returns the non-empty lines of s.
156+func lines(s string) []string {
157+ var out []string
158+ for _, l := range strings.Split(s, "\n") {
159+ if l != "" {
160+ out = append(out, l)
161+ }
162+ }
163+ return out
164+}
165+
166+func mustID(t testing.TB, s string) ObjectID {
167+ t.Helper()
168+ id, err := ParseID(strings.TrimSpace(s))
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ return id
173+}
174+
175+// textLines builds a compressible, delta-friendly text of n lines.
176+func textLines(n, seed int) []byte {
177+ var b bytes.Buffer
178+ for i := 0; i < n; i++ {
179+ fmt.Fprintf(&b, "line %d of the file, seed %d, some filler text to make it compress\n", i, (i*7+seed)%97)
180+ }
181+ return b.Bytes()
182+}
183+
184+// ambiguousPair finds the contents of two blobs whose ids share their first
185+// 4 hex digits.
186+func ambiguousPair(t *testing.T) (a, b []byte) {
187+ t.Helper()
188+ seen := map[string]int{}
189+ for i := 0; i < 200000; i++ {
190+ data := "ambiguous " + strconv.Itoa(i) + "\n"
191+ h := sha1.New()
192+ _, _ = fmt.Fprintf(h, "blob %d\x00%s", len(data), data)
193+ p := fmt.Sprintf("%x", h.Sum(nil)[:2])
194+ if j, ok := seen[p]; ok {
195+ return []byte("ambiguous " + strconv.Itoa(j) + "\n"), []byte(data)
196+ }
197+ seen[p] = i
198+ }
199+ t.Fatal("no ambiguous pair found")
200+ return nil, nil
201+}
202+
203+// writeBlob stores data as a loose blob and returns its id.
204+func (f *fx) writeBlob(data []byte) ObjectID {
205+ f.t.Helper()
206+ return mustID(f.t, f.gitIn(data, "hash-object", "-w", "--stdin"))
207+}
@@ -0,0 +1,207 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "crypto/sha1"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "testing"
15+)
16+
17+// fx builds a fixture repository with the git binary, which is also the
18+// oracle of the tests.
19+type fx struct {
20+ t *testing.T
21+ dir string // the git directory (bare) or work/.git
22+ work string // work tree, "" for a bare repository
23+ n int // commit counter, drives the dates
24+}
25+
26+func gitEnv() []string {
27+ return append(os.Environ(),
28+ "GIT_CONFIG_GLOBAL=/dev/null",
29+ "GIT_CONFIG_SYSTEM=/dev/null",
30+ "GIT_CONFIG_NOSYSTEM=1",
31+ "GIT_TERMINAL_PROMPT=0",
32+ "GIT_AUTHOR_NAME=Ada Lovelace",
33+ "GIT_AUTHOR_EMAIL=ada@example.com",
34+ "GIT_COMMITTER_NAME=Grace Hopper",
35+ "GIT_COMMITTER_EMAIL=grace@example.com",
36+ "LC_ALL=C",
37+ )
38+}
39+
40+func requireGit(t testing.TB) {
41+ t.Helper()
42+ if _, err := exec.LookPath("git"); err != nil {
43+ t.Skip("git binary not available")
44+ }
45+}
46+
47+// runGit runs git in dir and returns its stdout.
48+func runGit(t testing.TB, dir string, stdin []byte, extraEnv []string, args ...string) []byte {
49+ t.Helper()
50+ cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "gc.auto=0", "-c", "init.defaultBranch=main", "-c", "protocol.file.allow=always"}, args...)...)
51+ cmd.Dir = dir
52+ cmd.Env = append(gitEnv(), extraEnv...)
53+ if stdin != nil {
54+ cmd.Stdin = bytes.NewReader(stdin)
55+ }
56+ var out, errb bytes.Buffer
57+ cmd.Stdout, cmd.Stderr = &out, &errb
58+ if err := cmd.Run(); err != nil {
59+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, errb.String())
60+ }
61+ return out.Bytes()
62+}
63+
64+// newFx creates an empty non-bare repository; Open is given work, so the
65+// ".git" directory handling is covered.
66+func newFx(t *testing.T) *fx {
67+ requireGit(t)
68+ work := filepath.Join(t.TempDir(), "work")
69+ if err := os.MkdirAll(work, 0o755); err != nil {
70+ t.Fatal(err)
71+ }
72+ f := &fx{t: t, work: work, dir: filepath.Join(work, ".git")}
73+ f.git("init", "-q")
74+ return f
75+}
76+
77+// newBareFx creates an empty bare repository.
78+func newBareFx(t *testing.T) *fx {
79+ requireGit(t)
80+ dir := filepath.Join(t.TempDir(), "bare.git")
81+ f := &fx{t: t, dir: dir}
82+ runGit(t, t.TempDir(), nil, nil, "init", "-q", "--bare", dir)
83+ return f
84+}
85+
86+func (f *fx) root() string {
87+ if f.work != "" {
88+ return f.work
89+ }
90+ return f.dir
91+}
92+
93+func (f *fx) git(args ...string) string {
94+ f.t.Helper()
95+ return strings.TrimRight(string(runGit(f.t, f.root(), nil, f.env(), args...)), "\n")
96+}
97+
98+func (f *fx) gitIn(stdin []byte, args ...string) string {
99+ f.t.Helper()
100+ return strings.TrimRight(string(runGit(f.t, f.root(), stdin, f.env(), args...)), "\n")
101+}
102+
103+func (f *fx) env() []string {
104+ if f.work != "" {
105+ return nil
106+ }
107+ return []string{"GIT_DIR=" + f.dir}
108+}
109+
110+func (f *fx) tick() []string {
111+ f.n++
112+ d := fmt.Sprintf("%d +0%d00", 1700000000+f.n*3600, f.n%10)
113+ if f.n%3 == 0 {
114+ d = fmt.Sprintf("%d -0%d30", 1700000000+f.n*3600, f.n%9)
115+ }
116+ return []string{"GIT_AUTHOR_DATE=" + d, "GIT_COMMITTER_DATE=" + d}
117+}
118+
119+func (f *fx) write(path string, data []byte, mode os.FileMode) {
120+ f.t.Helper()
121+ full := filepath.Join(f.work, path)
122+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
123+ f.t.Fatal(err)
124+ }
125+ if err := os.WriteFile(full, data, mode); err != nil {
126+ f.t.Fatal(err)
127+ }
128+ if err := os.Chmod(full, mode); err != nil {
129+ f.t.Fatal(err)
130+ }
131+}
132+
133+// commit stages everything and commits with the next date.
134+func (f *fx) commit(msg string) string {
135+ f.t.Helper()
136+ f.git("add", "-A")
137+ runGit(f.t, f.work, nil, f.tick(), "commit", "-q", "--allow-empty", "--allow-empty-message", "-m", msg)
138+ return f.git("rev-parse", "HEAD")
139+}
140+
141+func (f *fx) open(opts ...Option) *Repo {
142+ f.t.Helper()
143+ path := f.dir
144+ if f.work != "" {
145+ path = f.work
146+ }
147+ r, err := Open(path, opts...)
148+ if err != nil {
149+ f.t.Fatalf("Open: %v", err)
150+ }
151+ f.t.Cleanup(func() { _ = r.Close() })
152+ return r
153+}
154+
155+// lines returns the non-empty lines of s.
156+func lines(s string) []string {
157+ var out []string
158+ for _, l := range strings.Split(s, "\n") {
159+ if l != "" {
160+ out = append(out, l)
161+ }
162+ }
163+ return out
164+}
165+
166+func mustID(t testing.TB, s string) ObjectID {
167+ t.Helper()
168+ id, err := ParseID(strings.TrimSpace(s))
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ return id
173+}
174+
175+// textLines builds a compressible, delta-friendly text of n lines.
176+func textLines(n, seed int) []byte {
177+ var b bytes.Buffer
178+ for i := 0; i < n; i++ {
179+ fmt.Fprintf(&b, "line %d of the file, seed %d, some filler text to make it compress\n", i, (i*7+seed)%97)
180+ }
181+ return b.Bytes()
182+}
183+
184+// ambiguousPair finds the contents of two blobs whose ids share their first
185+// 4 hex digits.
186+func ambiguousPair(t *testing.T) (a, b []byte) {
187+ t.Helper()
188+ seen := map[string]int{}
189+ for i := 0; i < 200000; i++ {
190+ data := "ambiguous " + strconv.Itoa(i) + "\n"
191+ h := sha1.New()
192+ _, _ = fmt.Fprintf(h, "blob %d\x00%s", len(data), data)
193+ p := fmt.Sprintf("%x", h.Sum(nil)[:2])
194+ if j, ok := seen[p]; ok {
195+ return []byte("ambiguous " + strconv.Itoa(j) + "\n"), []byte(data)
196+ }
197+ seen[p] = i
198+ }
199+ t.Fatal("no ambiguous pair found")
200+ return nil, nil
201+}
202+
203+// writeBlob stores data as a loose blob and returns its id.
204+func (f *fx) writeBlob(data []byte) ObjectID {
205+ f.t.Helper()
206+ return mustID(f.t, f.gitIn(data, "hash-object", "-w", "--stdin"))
207+}
A open_test.go +264 -0
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestConfigChecks(t *testing.T) {
14+ ok := []string{
15+ "",
16+ "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
17+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha1\n\trefstorage = files\n",
18+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tObjectFormat = SHA1 # comment\n",
19+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tnoop = true\n\tworktreeConfig = true\n",
20+ "[remote \"origin\"]\n\turl = x\n[extensions-lookalike]\n\tfoo = bar\n",
21+ "# comment\n; comment\n[core]\nrepositoryformatversion=0\n",
22+ "[branch \"extensions\"]\n\tobjectformat = sha256\n",
23+ }
24+ for _, c := range ok {
25+ if err := checkConfigData([]byte(c)); err != nil {
26+ t.Errorf("config %q: %v", c, err)
27+ }
28+ }
29+ bad := map[string]string{
30+ "[core]\n\trepositoryformatversion = 2\n": "repositoryformatversion",
31+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha256\n": "objectformat",
32+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefstorage = reftable\n": "refstorage",
33+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tpartialclone = origin\n": "partialclone",
34+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tcompatobjectformat = x\n": "compatobjectformat",
35+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tfutureThing = 1\n": "futurething",
36+ }
37+ for c, want := range bad {
38+ err := checkConfigData([]byte(c))
39+ if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), want) {
40+ t.Errorf("config %q: %v, want ErrUnsupported mentioning %q", c, err, want)
41+ }
42+ }
43+ if err := checkConfigData([]byte("[core\n")); !errors.Is(err, ErrCorrupt) {
44+ t.Errorf("broken section: %v", err)
45+ }
46+}
47+
48+func TestOpenUnsupported(t *testing.T) {
49+ cases := []struct {
50+ name string
51+ setup func(t *testing.T, f *fx)
52+ want string
53+ }{
54+ {"sha256", func(t *testing.T, f *fx) {
55+ f.git("config", "core.repositoryformatversion", "1")
56+ f.git("config", "extensions.objectformat", "sha256")
57+ }, "objectformat"},
58+ {"reftable", func(t *testing.T, f *fx) {
59+ f.git("config", "core.repositoryformatversion", "1")
60+ f.git("config", "extensions.refstorage", "reftable")
61+ }, "refstorage"},
62+ {"extension", func(t *testing.T, f *fx) {
63+ f.git("config", "core.repositoryformatversion", "1")
64+ f.git("config", "extensions.partialclone", "origin")
65+ }, "partialclone"},
66+ {"version", func(t *testing.T, f *fx) { f.git("config", "core.repositoryformatversion", "3") }, "formatversion"},
67+ {"shallow", func(t *testing.T, f *fx) {
68+ writeFile(t, f.dir+"/shallow", f.git("rev-parse", "HEAD")+"\n")
69+ }, "shallow"},
70+ {"grafts", func(t *testing.T, f *fx) {
71+ writeFile(t, f.dir+"/info/grafts", f.git("rev-parse", "HEAD")+"\n")
72+ }, "grafts"},
73+ {"replace", func(t *testing.T, f *fx) {
74+ old := f.git("rev-parse", "HEAD")
75+ f.write("b", []byte("b"), 0o644)
76+ f.commit("two")
77+ f.git("replace", old, "HEAD")
78+ }, "replace"},
79+ {"replace-packed", func(t *testing.T, f *fx) {
80+ old := f.git("rev-parse", "HEAD")
81+ f.write("b", []byte("b"), 0o644)
82+ f.commit("two")
83+ f.git("replace", old, "HEAD")
84+ f.git("pack-refs", "--all")
85+ }, "replace"},
86+ {"alternates", func(t *testing.T, f *fx) {
87+ writeFile(t, f.dir+"/objects/info/alternates", t.TempDir()+"\n")
88+ }, "alternate"},
89+ {"promisor", func(t *testing.T, f *fx) {
90+ f.git("repack", "-ad")
91+ writeFile(t, firstPackIdx(t, f)[:len(firstPackIdx(t, f))-4]+".promisor", "")
92+ }, "partial clone"},
93+ {"midx-with-missing-idx", func(t *testing.T, f *fx) {
94+ f.git("repack", "-ad")
95+ f.git("multi-pack-index", "write")
96+ if err := os.Remove(firstPackIdx(t, f)); err != nil {
97+ t.Fatal(err)
98+ }
99+ }, "multi-pack-index"},
100+ {"worktree-gitfile", nil, "gitfile"},
101+ }
102+ for _, c := range cases {
103+ t.Run(c.name, func(t *testing.T) {
104+ f := newFx(t)
105+ f.write("a", []byte("a"), 0o644)
106+ f.commit("one")
107+ path := f.work
108+ if c.setup != nil {
109+ c.setup(t, f)
110+ } else {
111+ path = t.TempDir()
112+ writeFile(t, path+"/.git", "gitdir: /elsewhere\n")
113+ }
114+ _, err := Open(path)
115+ if !errors.Is(err, ErrUnsupported) || !strings.Contains(strings.ToLower(err.Error()), c.want) {
116+ t.Fatalf("Open = %v, want ErrUnsupported mentioning %q", err, c.want)
117+ }
118+ })
119+ }
120+}
121+
122+func TestEmptyReplaceDirIsFine(t *testing.T) {
123+ f := newFx(t)
124+ f.write("a", []byte("a"), 0o644)
125+ f.commit("one")
126+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
127+ t.Fatal(err)
128+ }
129+ f.open()
130+}
131+
132+func TestMidxIgnoredWhenIdxFilesPresent(t *testing.T) {
133+ f := newFx(t)
134+ mixedFixture(t, f)
135+ f.git("repack", "-ad")
136+ f.git("multi-pack-index", "write")
137+ if !exists(f.dir + "/objects/pack/multi-pack-index") {
138+ t.Fatal("fixture has no multi-pack-index")
139+ }
140+ r := f.open()
141+ checkAll(t, r, f.root())
142+ // Two packs and a midx covering both.
143+ f.write("more.txt", []byte("more\n"), 0o644)
144+ f.commit("more")
145+ f.git("repack", "-d")
146+ f.git("multi-pack-index", "write")
147+ if err := r.Refresh(); err != nil {
148+ t.Fatal(err)
149+ }
150+ checkAll(t, r, f.root())
151+}
152+
153+func TestIncrementalMidxUnsupported(t *testing.T) {
154+ f := newFx(t)
155+ f.write("a", []byte("a"), 0o644)
156+ f.commit("one")
157+ f.git("repack", "-ad")
158+ if out, err := f.gitErr("multi-pack-index", "write", "--incremental"); err != nil {
159+ t.Skipf("this git cannot write an incremental multi-pack-index: %v %s", err, out)
160+ }
161+ if _, err := Open(f.work); !errors.Is(err, ErrUnsupported) {
162+ t.Fatalf("Open = %v, want ErrUnsupported", err)
163+ }
164+}
165+
166+func TestRefreshSeesChanges(t *testing.T) {
167+ f := newBareFx(t)
168+ src := newFx(t)
169+ src.write("a", []byte("a\n"), 0o644)
170+ c1 := src.commit("one")
171+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
172+ r := f.open()
173+ if id, err := r.ResolveRef("refs/heads/main"); err != nil || id.String() != c1 {
174+ t.Fatalf("main = %s %v", id, err)
175+ }
176+ // A push: new objects (a new pack), updated ref, new ref and tag.
177+ src.write("b", []byte("b\n"), 0o644)
178+ c2 := src.commit("two")
179+ src.git("tag", "-a", "-m", "v1", "v1")
180+ f.git("fetch", "-q", "--tags", src.dir, "+refs/heads/main:refs/heads/main", "+refs/heads/main:refs/heads/second")
181+ if err := r.Refresh(); err != nil {
182+ t.Fatal(err)
183+ }
184+ for _, name := range []string{"refs/heads/main", "refs/heads/second"} {
185+ if id, err := r.ResolveRef(name); err != nil || id.String() != c2 {
186+ t.Fatalf("%s = %s %v", name, id, err)
187+ }
188+ }
189+ var c Commit
190+ if err := r.ReadCommit(mustID(t, c2), &c); err != nil {
191+ t.Fatal(err)
192+ }
193+ compareRefs(t, r, f.root())
194+ compareObjects(t, r, f.root())
195+
196+ // pack-refs then a repack that deletes the old packs.
197+ f.git("pack-refs", "--all")
198+ f.git("repack", "-adf")
199+ if err := r.Refresh(); err != nil {
200+ t.Fatal(err)
201+ }
202+ compareRefs(t, r, f.root())
203+ compareObjects(t, r, f.root())
204+ if n := len(r.snap.Load().packs); n != 1 {
205+ t.Fatalf("%d packs after repack -ad, want 1", n)
206+ }
207+}
208+
209+// An object that arrives in a new pack is found without Refresh.
210+func TestMissingObjectRescansPacks(t *testing.T) {
211+ f := newBareFx(t)
212+ src := newFx(t)
213+ src.write("a", []byte("a\n"), 0o644)
214+ src.commit("one")
215+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
216+ r := f.open()
217+ src.write("b", []byte("b\n"), 0o644)
218+ c2 := src.commit("two")
219+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
220+ f.git("repack", "-adf")
221+ var c Commit
222+ if err := r.ReadCommit(mustID(t, c2), &c); err != nil {
223+ t.Fatalf("new object: %v", err)
224+ }
225+ if _, err := r.ResolvePrefix(c2[:10]); err != nil {
226+ t.Fatalf("new prefix: %v", err)
227+ }
228+}
229+
230+func TestClosedRepo(t *testing.T) {
231+ f := newFx(t)
232+ f.write("a", []byte("a"), 0o644)
233+ c := f.commit("one")
234+ r := f.open()
235+ _ = r.Close()
236+ _ = r.Close()
237+ if _, _, err := r.ReadObject(mustID(t, c), nil); !errors.Is(err, ErrClosed) {
238+ t.Fatalf("ReadObject after Close = %v", err)
239+ }
240+ if err := r.Refresh(); !errors.Is(err, ErrClosed) {
241+ t.Fatalf("Refresh after Close = %v", err)
242+ }
243+}
244+
245+func TestRefreshDetectsNewUnsupported(t *testing.T) {
246+ f := newFx(t)
247+ f.write("a", []byte("a"), 0o644)
248+ f.commit("one")
249+ r := f.open()
250+ writeFile(t, f.dir+"/shallow", f.git("rev-parse", "HEAD")+"\n")
251+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
252+ t.Fatalf("Refresh = %v, want ErrUnsupported", err)
253+ }
254+}
255+
256+func writeFile(t *testing.T, path, data string) {
257+ t.Helper()
258+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
259+ t.Fatal(err)
260+ }
261+ if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
262+ t.Fatal(err)
263+ }
264+}
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestConfigChecks(t *testing.T) {
14+ ok := []string{
15+ "",
16+ "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
17+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha1\n\trefstorage = files\n",
18+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tObjectFormat = SHA1 # comment\n",
19+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tnoop = true\n\tworktreeConfig = true\n",
20+ "[remote \"origin\"]\n\turl = x\n[extensions-lookalike]\n\tfoo = bar\n",
21+ "# comment\n; comment\n[core]\nrepositoryformatversion=0\n",
22+ "[branch \"extensions\"]\n\tobjectformat = sha256\n",
23+ }
24+ for _, c := range ok {
25+ if err := checkConfigData([]byte(c)); err != nil {
26+ t.Errorf("config %q: %v", c, err)
27+ }
28+ }
29+ bad := map[string]string{
30+ "[core]\n\trepositoryformatversion = 2\n": "repositoryformatversion",
31+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tobjectformat = sha256\n": "objectformat",
32+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefstorage = reftable\n": "refstorage",
33+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tpartialclone = origin\n": "partialclone",
34+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tcompatobjectformat = x\n": "compatobjectformat",
35+ "[core]\n\trepositoryformatversion = 1\n[extensions]\n\tfutureThing = 1\n": "futurething",
36+ }
37+ for c, want := range bad {
38+ err := checkConfigData([]byte(c))
39+ if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), want) {
40+ t.Errorf("config %q: %v, want ErrUnsupported mentioning %q", c, err, want)
41+ }
42+ }
43+ if err := checkConfigData([]byte("[core\n")); !errors.Is(err, ErrCorrupt) {
44+ t.Errorf("broken section: %v", err)
45+ }
46+}
47+
48+func TestOpenUnsupported(t *testing.T) {
49+ cases := []struct {
50+ name string
51+ setup func(t *testing.T, f *fx)
52+ want string
53+ }{
54+ {"sha256", func(t *testing.T, f *fx) {
55+ f.git("config", "core.repositoryformatversion", "1")
56+ f.git("config", "extensions.objectformat", "sha256")
57+ }, "objectformat"},
58+ {"reftable", func(t *testing.T, f *fx) {
59+ f.git("config", "core.repositoryformatversion", "1")
60+ f.git("config", "extensions.refstorage", "reftable")
61+ }, "refstorage"},
62+ {"extension", func(t *testing.T, f *fx) {
63+ f.git("config", "core.repositoryformatversion", "1")
64+ f.git("config", "extensions.partialclone", "origin")
65+ }, "partialclone"},
66+ {"version", func(t *testing.T, f *fx) { f.git("config", "core.repositoryformatversion", "3") }, "formatversion"},
67+ {"shallow", func(t *testing.T, f *fx) {
68+ writeFile(t, f.dir+"/shallow", f.git("rev-parse", "HEAD")+"\n")
69+ }, "shallow"},
70+ {"grafts", func(t *testing.T, f *fx) {
71+ writeFile(t, f.dir+"/info/grafts", f.git("rev-parse", "HEAD")+"\n")
72+ }, "grafts"},
73+ {"replace", func(t *testing.T, f *fx) {
74+ old := f.git("rev-parse", "HEAD")
75+ f.write("b", []byte("b"), 0o644)
76+ f.commit("two")
77+ f.git("replace", old, "HEAD")
78+ }, "replace"},
79+ {"replace-packed", func(t *testing.T, f *fx) {
80+ old := f.git("rev-parse", "HEAD")
81+ f.write("b", []byte("b"), 0o644)
82+ f.commit("two")
83+ f.git("replace", old, "HEAD")
84+ f.git("pack-refs", "--all")
85+ }, "replace"},
86+ {"alternates", func(t *testing.T, f *fx) {
87+ writeFile(t, f.dir+"/objects/info/alternates", t.TempDir()+"\n")
88+ }, "alternate"},
89+ {"promisor", func(t *testing.T, f *fx) {
90+ f.git("repack", "-ad")
91+ writeFile(t, firstPackIdx(t, f)[:len(firstPackIdx(t, f))-4]+".promisor", "")
92+ }, "partial clone"},
93+ {"midx-with-missing-idx", func(t *testing.T, f *fx) {
94+ f.git("repack", "-ad")
95+ f.git("multi-pack-index", "write")
96+ if err := os.Remove(firstPackIdx(t, f)); err != nil {
97+ t.Fatal(err)
98+ }
99+ }, "multi-pack-index"},
100+ {"worktree-gitfile", nil, "gitfile"},
101+ }
102+ for _, c := range cases {
103+ t.Run(c.name, func(t *testing.T) {
104+ f := newFx(t)
105+ f.write("a", []byte("a"), 0o644)
106+ f.commit("one")
107+ path := f.work
108+ if c.setup != nil {
109+ c.setup(t, f)
110+ } else {
111+ path = t.TempDir()
112+ writeFile(t, path+"/.git", "gitdir: /elsewhere\n")
113+ }
114+ _, err := Open(path)
115+ if !errors.Is(err, ErrUnsupported) || !strings.Contains(strings.ToLower(err.Error()), c.want) {
116+ t.Fatalf("Open = %v, want ErrUnsupported mentioning %q", err, c.want)
117+ }
118+ })
119+ }
120+}
121+
122+func TestEmptyReplaceDirIsFine(t *testing.T) {
123+ f := newFx(t)
124+ f.write("a", []byte("a"), 0o644)
125+ f.commit("one")
126+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
127+ t.Fatal(err)
128+ }
129+ f.open()
130+}
131+
132+func TestMidxIgnoredWhenIdxFilesPresent(t *testing.T) {
133+ f := newFx(t)
134+ mixedFixture(t, f)
135+ f.git("repack", "-ad")
136+ f.git("multi-pack-index", "write")
137+ if !exists(f.dir + "/objects/pack/multi-pack-index") {
138+ t.Fatal("fixture has no multi-pack-index")
139+ }
140+ r := f.open()
141+ checkAll(t, r, f.root())
142+ // Two packs and a midx covering both.
143+ f.write("more.txt", []byte("more\n"), 0o644)
144+ f.commit("more")
145+ f.git("repack", "-d")
146+ f.git("multi-pack-index", "write")
147+ if err := r.Refresh(); err != nil {
148+ t.Fatal(err)
149+ }
150+ checkAll(t, r, f.root())
151+}
152+
153+func TestIncrementalMidxUnsupported(t *testing.T) {
154+ f := newFx(t)
155+ f.write("a", []byte("a"), 0o644)
156+ f.commit("one")
157+ f.git("repack", "-ad")
158+ if out, err := f.gitErr("multi-pack-index", "write", "--incremental"); err != nil {
159+ t.Skipf("this git cannot write an incremental multi-pack-index: %v %s", err, out)
160+ }
161+ if _, err := Open(f.work); !errors.Is(err, ErrUnsupported) {
162+ t.Fatalf("Open = %v, want ErrUnsupported", err)
163+ }
164+}
165+
166+func TestRefreshSeesChanges(t *testing.T) {
167+ f := newBareFx(t)
168+ src := newFx(t)
169+ src.write("a", []byte("a\n"), 0o644)
170+ c1 := src.commit("one")
171+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
172+ r := f.open()
173+ if id, err := r.ResolveRef("refs/heads/main"); err != nil || id.String() != c1 {
174+ t.Fatalf("main = %s %v", id, err)
175+ }
176+ // A push: new objects (a new pack), updated ref, new ref and tag.
177+ src.write("b", []byte("b\n"), 0o644)
178+ c2 := src.commit("two")
179+ src.git("tag", "-a", "-m", "v1", "v1")
180+ f.git("fetch", "-q", "--tags", src.dir, "+refs/heads/main:refs/heads/main", "+refs/heads/main:refs/heads/second")
181+ if err := r.Refresh(); err != nil {
182+ t.Fatal(err)
183+ }
184+ for _, name := range []string{"refs/heads/main", "refs/heads/second"} {
185+ if id, err := r.ResolveRef(name); err != nil || id.String() != c2 {
186+ t.Fatalf("%s = %s %v", name, id, err)
187+ }
188+ }
189+ var c Commit
190+ if err := r.ReadCommit(mustID(t, c2), &c); err != nil {
191+ t.Fatal(err)
192+ }
193+ compareRefs(t, r, f.root())
194+ compareObjects(t, r, f.root())
195+
196+ // pack-refs then a repack that deletes the old packs.
197+ f.git("pack-refs", "--all")
198+ f.git("repack", "-adf")
199+ if err := r.Refresh(); err != nil {
200+ t.Fatal(err)
201+ }
202+ compareRefs(t, r, f.root())
203+ compareObjects(t, r, f.root())
204+ if n := len(r.snap.Load().packs); n != 1 {
205+ t.Fatalf("%d packs after repack -ad, want 1", n)
206+ }
207+}
208+
209+// An object that arrives in a new pack is found without Refresh.
210+func TestMissingObjectRescansPacks(t *testing.T) {
211+ f := newBareFx(t)
212+ src := newFx(t)
213+ src.write("a", []byte("a\n"), 0o644)
214+ src.commit("one")
215+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
216+ r := f.open()
217+ src.write("b", []byte("b\n"), 0o644)
218+ c2 := src.commit("two")
219+ f.git("fetch", "-q", src.dir, "+refs/heads/main:refs/heads/main")
220+ f.git("repack", "-adf")
221+ var c Commit
222+ if err := r.ReadCommit(mustID(t, c2), &c); err != nil {
223+ t.Fatalf("new object: %v", err)
224+ }
225+ if _, err := r.ResolvePrefix(c2[:10]); err != nil {
226+ t.Fatalf("new prefix: %v", err)
227+ }
228+}
229+
230+func TestClosedRepo(t *testing.T) {
231+ f := newFx(t)
232+ f.write("a", []byte("a"), 0o644)
233+ c := f.commit("one")
234+ r := f.open()
235+ _ = r.Close()
236+ _ = r.Close()
237+ if _, _, err := r.ReadObject(mustID(t, c), nil); !errors.Is(err, ErrClosed) {
238+ t.Fatalf("ReadObject after Close = %v", err)
239+ }
240+ if err := r.Refresh(); !errors.Is(err, ErrClosed) {
241+ t.Fatalf("Refresh after Close = %v", err)
242+ }
243+}
244+
245+func TestRefreshDetectsNewUnsupported(t *testing.T) {
246+ f := newFx(t)
247+ f.write("a", []byte("a"), 0o644)
248+ f.commit("one")
249+ r := f.open()
250+ writeFile(t, f.dir+"/shallow", f.git("rev-parse", "HEAD")+"\n")
251+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
252+ t.Fatalf("Refresh = %v, want ErrUnsupported", err)
253+ }
254+}
255+
256+func writeFile(t *testing.T, path, data string) {
257+ t.Helper()
258+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
259+ t.Fatal(err)
260+ }
261+ if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
262+ t.Fatal(err)
263+ }
264+}
A parse_test.go +538 -0
@@ -0,0 +1,538 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "compress/zlib"
8+ "crypto/sha1"
9+ "encoding/binary"
10+ "errors"
11+ "fmt"
12+ "strings"
13+ "testing"
14+)
15+
16+func TestObjectID(t *testing.T) {
17+ const hex = "0123456789abcdef0123456789abcdef01234567"
18+ id, err := ParseID(hex)
19+ if err != nil || id.String() != hex || id.IsZero() {
20+ t.Fatalf("ParseID = %s %v", id, err)
21+ }
22+ if up, err := ParseID(strings.ToUpper(hex)); err != nil || up != id {
23+ t.Fatalf("upper case: %v", err)
24+ }
25+ for _, bad := range []string{"", "abc", hex[:39], hex + "0", strings.Repeat("g", 40), hex[:39] + "\x00"} {
26+ if _, err := ParseID(bad); !errors.Is(err, ErrInvalidID) {
27+ t.Errorf("ParseID(%q) = %v", bad, err)
28+ }
29+ }
30+ if !(ObjectID{}).IsZero() {
31+ t.Error("zero id not zero")
32+ }
33+ buf := make([]byte, 0, 64)
34+ out := id.AppendHex(append(buf, "x:"...))
35+ if string(out) != "x:"+hex {
36+ t.Errorf("AppendHex = %s", out)
37+ }
38+ if n := testing.AllocsPerRun(100, func() { _ = id.AppendHex(buf[:0]) }); n != 0 {
39+ t.Errorf("AppendHex allocates %v", n)
40+ }
41+}
42+
43+// varint encodes a delta header size.
44+func varint(v uint64) []byte {
45+ var b []byte
46+ for {
47+ c := byte(v & 0x7f)
48+ v >>= 7
49+ if v != 0 {
50+ b = append(b, c|0x80)
51+ } else {
52+ return append(b, c)
53+ }
54+ }
55+}
56+
57+func copyOp(off, size uint32) []byte {
58+ op := byte(0x80)
59+ var args []byte
60+ for i := 0; i < 4; i++ {
61+ if b := byte(off >> (8 * i)); b != 0 {
62+ op |= 1 << i
63+ args = append(args, b)
64+ }
65+ }
66+ for i := 0; i < 3; i++ {
67+ if b := byte(size >> (8 * i)); b != 0 {
68+ op |= 0x10 << i
69+ args = append(args, b)
70+ }
71+ }
72+ return append([]byte{op}, args...)
73+}
74+
75+func TestApplyDelta(t *testing.T) {
76+ base := []byte("hello, delta world")
77+ mk := func(baseSize, resSize uint64, ops ...[]byte) []byte {
78+ d := append(varint(baseSize), varint(resSize)...)
79+ for _, o := range ops {
80+ d = append(d, o...)
81+ }
82+ return d
83+ }
84+ ins := func(s string) []byte { return append([]byte{byte(len(s))}, s...) }
85+ good := mk(uint64(len(base)), 12, copyOp(0, 5), ins(" Go"), copyOp(11, 4))
86+ got, err := patch(base, good, 1<<20)
87+ if err != nil || string(got) != "hello Go"+string(base[11:15]) {
88+ t.Fatalf("patch = %q, %v", got, err)
89+ }
90+ // copy size 0 means 0x10000
91+ bigBase := bytes.Repeat([]byte{'x'}, 0x10000)
92+ got, err = patch(bigBase, mk(0x10000, 0x10000, []byte{0x80}), 1<<20)
93+ if err != nil || !bytes.Equal(got, bigBase) {
94+ t.Fatalf("copy 0x10000: %v", err)
95+ }
96+ bad := map[string][]byte{
97+ "empty": nil,
98+ "truncated header": {0x80},
99+ "base size": mk(3, 1, ins("a")),
100+ "copy outside": mk(uint64(len(base)), 4, copyOp(15, 4)),
101+ "copy huge": mk(uint64(len(base)), 4, copyOp(0xffffffff, 0xffffff)),
102+ "insert past end": mk(uint64(len(base)), 9, []byte{9, 'a'}),
103+ "result too long": mk(uint64(len(base)), 1, ins("ab")),
104+ "result too short": mk(uint64(len(base)), 5, ins("ab")),
105+ "opcode zero": mk(uint64(len(base)), 1, []byte{0}),
106+ "truncated copy": mk(uint64(len(base)), 1, []byte{0x81}),
107+ "huge varint": append(bytes.Repeat([]byte{0xff}, 12), 0x01),
108+ }
109+ for name, d := range bad {
110+ if _, err := patch(base, d, 1<<20); !errors.Is(err, ErrCorrupt) {
111+ t.Errorf("%s: %v, want ErrCorrupt", name, err)
112+ }
113+ }
114+ if _, err := patch(base, mk(uint64(len(base)), 1<<40, ins("a")), 1<<20); !errors.Is(err, ErrTooLarge) {
115+ t.Errorf("huge result: %v, want ErrTooLarge", err)
116+ }
117+}
118+
119+func TestParseEntryHeader(t *testing.T) {
120+ mk := func(body ...byte) []byte {
121+ d := append([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"), body...)
122+ return append(d, make([]byte, 20)...)
123+ }
124+ // blob, size 5
125+ h, err := parseEntryHeader(mk(0x35, 0x78, 0x9c), 12)
126+ if err != nil || h.typ != 3 || h.size != 5 || h.data != 13 {
127+ t.Fatalf("blob header = %+v %v", h, err)
128+ }
129+ // size 0x1234 : 4 low bits 4, then 7 bits ...
130+ h, err = parseEntryHeader(mk(0xb4, 0x23, 0x78), 12) // type 3 continued
131+ if err != nil || h.size != 4|0x23<<4 {
132+ t.Fatalf("multi byte size = %+v %v", h, err)
133+ }
134+ for name, d := range map[string][]byte{
135+ "unterminated": mk(bytes.Repeat([]byte{0xbf}, 20)...),
136+ "bad type 0": mk(0x05, 0x78),
137+ "bad type 5": mk(0x55, 0x78),
138+ "ofs no data": mk(0x65),
139+ "ofs outside": mk(0x65, 0x7f, 0x78),
140+ "ref truncated": mk(0x75, 1, 2, 3),
141+ } {
142+ off := uint64(12)
143+ if _, err := parseEntryHeader(d, off); !errors.Is(err, ErrCorrupt) {
144+ t.Errorf("%s: %v", name, err)
145+ }
146+ }
147+ if _, err := parseEntryHeader(mk(0x35, 0x78), 5); !errors.Is(err, ErrCorrupt) {
148+ t.Error("offset inside the pack header")
149+ }
150+ if _, err := parseEntryHeader(mk(0x35, 0x78), 1<<40); !errors.Is(err, ErrCorrupt) {
151+ t.Error("offset far outside")
152+ }
153+ if _, err := parseEntryHeader(nil, 12); !errors.Is(err, ErrCorrupt) {
154+ t.Error("nil pack")
155+ }
156+}
157+
158+func TestParseTreeErrors(t *testing.T) {
159+ good := append([]byte("100644 a\x00"), bytes.Repeat([]byte{1}, 20)...)
160+ good = append(good, append([]byte("40000 d\x00"), bytes.Repeat([]byte{2}, 20)...)...)
161+ n := 0
162+ if err := ParseTree(good, func(e Entry) bool { n++; return true }); err != nil || n != 2 {
163+ t.Fatalf("good tree: %d %v", n, err)
164+ }
165+ n = 0
166+ if err := ParseTree(good, func(e Entry) bool { n++; return false }); err != nil || n != 1 {
167+ t.Fatalf("early stop: %d %v", n, err)
168+ }
169+ for name, d := range map[string][]byte{
170+ "no space": []byte("100644"),
171+ "mode letters": []byte("10x644 a\x00" + strings.Repeat("a", 20)),
172+ "empty mode": []byte(" a\x00" + strings.Repeat("a", 20)),
173+ "long mode": []byte("10000000 a\x00" + strings.Repeat("a", 20)),
174+ "no nul": []byte("100644 abc"),
175+ "short id": []byte("100644 a\x00" + strings.Repeat("a", 19)),
176+ "empty name": []byte("100644 \x00" + strings.Repeat("a", 20)),
177+ } {
178+ if err := ParseTree(d, func(Entry) bool { return true }); !errors.Is(err, ErrCorrupt) {
179+ t.Errorf("%s: %v", name, err)
180+ }
181+ }
182+ if err := ParseTree(nil, func(Entry) bool { t.Fatal("called"); return true }); err != nil {
183+ t.Errorf("empty tree: %v", err)
184+ }
185+}
186+
187+func TestParseCommitEdgeCases(t *testing.T) {
188+ tree := strings.Repeat("a", 40)
189+ par := strings.Repeat("b", 40)
190+ var c Commit
191+ // No message at all, no trailing newline.
192+ if err := ParseCommit([]byte("tree "+tree+"\nauthor A <a@x> 1 +0000\ncommitter B <b@x> 2 -0130"), &c); err != nil {
193+ t.Fatal(err)
194+ }
195+ if c.Message != nil || c.Committer.Offset != -(3600+30*60) || len(c.Parents) != 0 {
196+ t.Errorf("commit = %+v", c)
197+ }
198+ // Two parents then reuse with zero parents keeps no stale state.
199+ if err := ParseCommit([]byte("tree "+tree+"\nparent "+par+"\nparent "+tree+"\nauthor A <a@x> 1 +0000\n\nmsg\n"), &c); err != nil || len(c.Parents) != 2 || string(c.Message) != "msg\n" {
200+ t.Fatalf("two parents: %+v %v", c, err)
201+ }
202+ if err := ParseCommit([]byte("tree "+tree+"\n\n"), &c); err != nil || len(c.Parents) != 0 || c.Author.Name != nil || len(c.Message) != 0 {
203+ t.Fatalf("reuse: %+v %v", c, err)
204+ }
205+ // Multi-line header continuation containing something that looks like a header.
206+ raw := "tree " + tree + "\nmergetag object x\n tag y\n parent " + par + "\n\n \nauthor A <a@x> 1 +0000\ncommitter C <c@x> 5 +0100\n\nbody"
207+ if err := ParseCommit([]byte(raw), &c); err != nil || len(c.Parents) != 0 {
208+ t.Fatalf("continuation: %+v %v", c, err)
209+ }
210+ // Odd signatures.
211+ for in, want := range map[string]Signature{
212+ "Name <e> 12 +0200": {Name: []byte("Name"), Email: []byte("e"), Time: 12, Offset: 7200},
213+ "<> 0 +0000": {Name: []byte{}, Email: []byte{}},
214+ "No Email 1 +0000": {Name: []byte("No Email 1 +0000")},
215+ "A <b> notatime +0100": {Name: []byte("A"), Email: []byte("b")},
216+ "A <b> 5 +01": {Name: []byte("A"), Email: []byte("b"), Time: 5},
217+ "A <b <c> 7 -0000": {Name: []byte("A"), Email: []byte("b <c"), Time: 7},
218+ "A <b> 99999999999999999999 +0000": {Name: []byte("A"), Email: []byte("b")},
219+ } {
220+ got := parseSignature([]byte(in))
221+ if !bytes.Equal(got.Name, want.Name) || !bytes.Equal(got.Email, want.Email) || got.Time != want.Time || got.Offset != want.Offset {
222+ t.Errorf("parseSignature(%q) = %+v %q %q, want %+v", in, got, got.Name, got.Email, want)
223+ }
224+ }
225+ for name, d := range map[string]string{
226+ "empty": "",
227+ "no tree": "author A <a@x> 1 +0000\n\nmsg",
228+ "bad tree": "tree xyz\n\n",
229+ "short tree": "tree " + tree[:39] + "\n\n",
230+ "bad parent": "tree " + tree + "\nparent zz\n\n",
231+ "double tree": "tree " + tree + "\ntree " + tree + "\n\n",
232+ } {
233+ if err := ParseCommit([]byte(d), &c); !errors.Is(err, ErrCorrupt) {
234+ t.Errorf("%s: %v", name, err)
235+ }
236+ }
237+}
238+
239+func TestParseTagErrors(t *testing.T) {
240+ obj := strings.Repeat("c", 40)
241+ var tg Tag
242+ if err := ParseTag([]byte("object "+obj+"\ntype commit\ntag v1\ntagger T <t@x> 3 +0000\n\nmsg"), &tg); err != nil || tg.Type != TypeCommit || string(tg.Name) != "v1" || string(tg.Message) != "msg" {
243+ t.Fatalf("tag = %+v %v", tg, err)
244+ }
245+ for name, d := range map[string]string{
246+ "empty": "",
247+ "no type": "object " + obj + "\n",
248+ "no obj": "type commit\n",
249+ "bad type": "object " + obj + "\ntype thing\n",
250+ "bad obj": "object zz\ntype commit\n",
251+ } {
252+ if err := ParseTag([]byte(d), &tg); !errors.Is(err, ErrCorrupt) {
253+ t.Errorf("%s: %v", name, err)
254+ }
255+ }
256+}
257+
258+func TestParsePackedRefsFormats(t *testing.T) {
259+ a, b, c := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40)
260+ p, err := parsePackedRefs("# pack-refs with: peeled fully-peeled sorted \n" + a + " refs/heads/main\n" + b + " refs/tags/v1\n^" + c + "\n" + c + " refs/tags/v2\n")
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ if e, _ := p.find("refs/tags/v1"); !e.peeledKnown || e.peeled.String() != c {
265+ t.Errorf("v1 = %+v", e)
266+ }
267+ if e, _ := p.find("refs/heads/main"); !e.peeledKnown || e.peeled.String() != a {
268+ t.Errorf("fully peeled: main = %+v", e)
269+ }
270+ // Without the header nothing is known.
271+ p, err = parsePackedRefs(a + " refs/tags/v1\n")
272+ if err != nil {
273+ t.Fatal(err)
274+ }
275+ if e, _ := p.find("refs/tags/v1"); e.peeledKnown {
276+ t.Errorf("unknown peel claimed known")
277+ }
278+ // Old "peeled" trait: tags known, branches not.
279+ p, _ = parsePackedRefs("# pack-refs with: peeled\n" + a + " refs/heads/x\n" + a + " refs/tags/t\n")
280+ if e, _ := p.find("refs/heads/x"); e.peeledKnown {
281+ t.Error("branch peel known with the peeled trait")
282+ }
283+ if e, _ := p.find("refs/tags/t"); !e.peeledKnown {
284+ t.Error("tag peel unknown with the peeled trait")
285+ }
286+ for _, bad := range []string{"^" + a + "\n", a + "\n", "zz refs/x\n", a + " \n", a + " refs/x\n^zz\n"} {
287+ if _, err := parsePackedRefs(bad); !errors.Is(err, ErrCorrupt) {
288+ t.Errorf("parsePackedRefs(%q) = %v", bad, err)
289+ }
290+ }
291+}
292+
293+func TestValidRefName(t *testing.T) {
294+ for _, ok := range []string{"refs/heads/main", "refs/heads/a/b", "refs/tags/v1.0", "refs/heads/é"} {
295+ if !validRefName(ok) {
296+ t.Errorf("%q rejected", ok)
297+ }
298+ }
299+ for _, bad := range []string{"", "HEAD", "refs", "refs/", "/refs/heads/x", "refs/heads/x/", "refs/heads/../x", "refs/heads/a..b", "refs/heads/.x", "refs/heads/x.lock",
300+ "refs/heads//x", "refs/heads/a b", "refs/heads/a~1", "refs/heads/a^", "refs/heads/a:b", "refs/heads/a?", "refs/heads/a*", "refs/heads/a[", "refs/heads/a\\b", "refs/heads/a@{1}", "refs/heads/\x01"} {
301+ if validRefName(bad) {
302+ t.Errorf("%q accepted", bad)
303+ }
304+ }
305+}
306+
307+// zlibBytes compresses b.
308+func zlibBytes(b []byte) []byte {
309+ var out bytes.Buffer
310+ w := zlib.NewWriter(&out)
311+ _, _ = w.Write(b)
312+ _ = w.Close()
313+ return out.Bytes()
314+}
315+
316+// entryBytes encodes a pack entry header followed by data.
317+func entryBytes(typ int, size int, extra []byte, data []byte) []byte {
318+ c := byte(typ<<4) | byte(size&0x0f)
319+ size >>= 4
320+ var out []byte
321+ for size > 0 {
322+ out = append(out, c|0x80)
323+ c = byte(size & 0x7f)
324+ size >>= 7
325+ }
326+ out = append(out, c)
327+ out = append(out, extra...)
328+ return append(out, zlibBytes(data)...)
329+}
330+
331+// buildPack assembles a pack file and its v2 index from entries (whose ids
332+// are given) and returns both.
333+func buildPack(entries [][]byte, ids []ObjectID, offs *[]uint64) (pack, idx []byte) {
334+ pack = append([]byte("PACK"), 0, 0, 0, 2, 0, 0, 0, byte(len(entries)))
335+ for _, e := range entries {
336+ *offs = append(*offs, uint64(len(pack)))
337+ pack = append(pack, e...)
338+ }
339+ sum := sha1.Sum(pack)
340+ pack = append(pack, sum[:]...)
341+
342+ order := make([]int, len(ids))
343+ for i := range order {
344+ order[i] = i
345+ }
346+ for i := range order { // insertion sort by id
347+ for j := i; j > 0 && bytes.Compare(ids[order[j]][:], ids[order[j-1]][:]) < 0; j-- {
348+ order[j], order[j-1] = order[j-1], order[j]
349+ }
350+ }
351+ idx = append([]byte{0xff, 't', 'O', 'c', 0, 0, 0, 2}, make([]byte, 1024)...)
352+ for i, k := range order {
353+ for b := int(ids[k][0]); b < 256; b++ {
354+ cur := binary.BigEndian.Uint32(idx[8+4*b:])
355+ if cur < uint32(i+1) {
356+ binary.BigEndian.PutUint32(idx[8+4*b:], uint32(i+1))
357+ }
358+ }
359+ }
360+ for _, k := range order {
361+ idx = append(idx, ids[k][:]...)
362+ }
363+ idx = append(idx, make([]byte, 4*len(order))...) // crc
364+ for _, k := range order {
365+ idx = binary.BigEndian.AppendUint32(idx, uint32((*offs)[k]))
366+ }
367+ idx = append(idx, pack[len(pack)-20:]...)
368+ isum := sha1.Sum(idx)
369+ return pack, append(idx, isum[:]...)
370+}
371+
372+func objectIDOf(typ string, data []byte) ObjectID {
373+ h := sha1.New()
374+ _, _ = fmt.Fprintf(h, "%s %d\x00", typ, len(data))
375+ _, _ = h.Write(data)
376+ var id ObjectID
377+ copy(id[:], h.Sum(nil))
378+ return id
379+}
380+
381+func TestParseIdxErrors(t *testing.T) {
382+ var offs []uint64
383+ blob := []byte("hello")
384+ _, idx := buildPack([][]byte{entryBytes(3, len(blob), nil, blob)}, []ObjectID{objectIDOf("blob", blob)}, &offs)
385+ x, err := parseIdx(idx)
386+ if err != nil || x.n != 1 {
387+ t.Fatalf("good idx: %v", err)
388+ }
389+ if _, ok := x.find(&ObjectID{}); ok {
390+ t.Error("found a missing id")
391+ }
392+ id := objectIDOf("blob", blob)
393+ if pos, ok := x.find(&id); !ok || pos != 0 {
394+ t.Error("id not found")
395+ }
396+ trunc := func(n int) []byte { return idx[:n] }
397+ mut := func(f func(b []byte)) []byte { b := bytes.Clone(idx); f(b); return b }
398+ for name, d := range map[string][]byte{
399+ "empty": nil,
400+ "short": trunc(100),
401+ "no trailer": trunc(len(idx) - 41),
402+ "magic": mut(func(b []byte) { b[0] = 0 }),
403+ "version 3": mut(func(b []byte) { b[7] = 3 }),
404+ "fanout down": mut(func(b []byte) { binary.BigEndian.PutUint32(b[8+4*10:], 5) }),
405+ "n too big": mut(func(b []byte) { binary.BigEndian.PutUint32(b[8+4*255:], 1<<31) }),
406+ "odd large part": append(bytes.Clone(idx), 1, 2, 3),
407+ } {
408+ if _, err := parseIdx(d); err == nil {
409+ t.Errorf("%s: accepted", name)
410+ }
411+ }
412+ // A large offset entry pointing outside of the table.
413+ d := mut(func(b []byte) {
414+ off := len(b) - 40 - 4
415+ binary.BigEndian.PutUint32(b[off:], 0x80000005)
416+ })
417+ x, err = parseIdx(d)
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if _, err := x.offset(0); !errors.Is(err, ErrCorrupt) {
422+ t.Errorf("large offset outside table: %v", err)
423+ }
424+}
425+
426+// TestCorruptPacks mutates a real pack on disk and checks that every read
427+// either succeeds with the right content or fails with ErrCorrupt, never
428+// panics.
429+func TestCorruptPacks(t *testing.T) {
430+ // A small hand built pack: a blob, a delta against it, a ref-delta.
431+ base := bytes.Repeat([]byte("0123456789abcdef"), 50)
432+ res := append(bytes.Clone(base[:400]), []byte("tail")...)
433+ delta := append(varint(uint64(len(base))), varint(uint64(len(res)))...)
434+ delta = append(delta, copyOp(0, 400)...)
435+ delta = append(delta, 4, 't', 'a', 'i', 'l')
436+ baseID, resID := objectIDOf("blob", base), objectIDOf("blob", res)
437+
438+ var offs []uint64
439+ e0 := entryBytes(3, len(base), nil, base)
440+ // ofs-delta distance to entry 0 is len(e0)
441+ ofs := []byte{byte(len(e0))}
442+ if len(e0) >= 128 {
443+ t.Fatal("fixture: e0 too long for a one byte offset")
444+ }
445+ e1 := entryBytes(objOfsDelta, len(delta), ofs, delta)
446+ res2 := append(bytes.Clone(base[:400]), []byte("TAIL")...)
447+ delta2 := append(varint(uint64(len(base))), varint(uint64(len(res2)))...)
448+ delta2 = append(delta2, copyOp(0, 400)...)
449+ delta2 = append(delta2, 4, 'T', 'A', 'I', 'L')
450+ e2 := entryBytes(objRefDelta, len(delta2), baseID[:], delta2)
451+ res2ID := objectIDOf("blob", res2)
452+ pk, idx := buildPack([][]byte{e0, e1, e2}, []ObjectID{baseID, resID, res2ID}, &offs)
453+
454+ dir := t.TempDir()
455+ write := func(pack, idxd []byte) *Repo {
456+ t.Helper()
457+ g := dir + "/g" + fmt.Sprint(len(pack)) + fmt.Sprint(len(idxd)) + fmt.Sprint(bytes.Count(pack, []byte{0}))
458+ writeFile(t, g+"/HEAD", "ref: refs/heads/main\n")
459+ writeFile(t, g+"/refs/.keep", "")
460+ name := fmt.Sprintf("pack-%x", pk[len(pk)-20:])
461+ writeFile(t, g+"/objects/pack/"+name+".pack", string(pack))
462+ writeFile(t, g+"/objects/pack/"+name+".idx", string(idxd))
463+ r, err := Open(g)
464+ if err != nil {
465+ return nil
466+ }
467+ t.Cleanup(func() { _ = r.Close() })
468+ return r
469+ }
470+ // Intact.
471+ r := write(pk, idx)
472+ if r == nil {
473+ t.Fatal("intact pack rejected")
474+ }
475+ for id, want := range map[ObjectID][]byte{baseID: base, resID: res, res2ID: res2} {
476+ _, got, err := r.ReadObject(id, nil)
477+ if err != nil || !bytes.Equal(got, want) {
478+ t.Fatalf("intact %s: %v", id, err)
479+ }
480+ if _, n, err := r.ObjectHeader(id); err != nil || n != int64(len(want)) {
481+ t.Fatalf("intact header %s: %d %v", id, n, err)
482+ }
483+ }
484+ // Every single byte flipped in the pack body and truncations.
485+ cases := 0
486+ check := func(pack []byte) {
487+ cases++
488+ r := write(pack, idx)
489+ if r == nil {
490+ return // rejected at Open: fine
491+ }
492+ for _, id := range []ObjectID{baseID, resID, res2ID} {
493+ _, _, err := r.ReadObject(id, nil)
494+ if err != nil && !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) {
495+ t.Fatalf("ReadObject: %v is not ErrCorrupt", err)
496+ }
497+ _, _, err = r.ObjectHeader(id)
498+ if err != nil && !errors.Is(err, ErrCorrupt) {
499+ t.Fatalf("ObjectHeader: %v is not ErrCorrupt", err)
500+ }
501+ if _, rc, err := r.OpenBlob(id); err == nil {
502+ _, rerr := readAll(rc)
503+ _ = rc.Close()
504+ if rerr != nil && !errors.Is(rerr, ErrCorrupt) {
505+ t.Fatalf("blob read: %v is not ErrCorrupt", rerr)
506+ }
507+ } else if !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) && !errors.Is(err, ErrWrongType) {
508+ t.Fatalf("OpenBlob: %v", err)
509+ }
510+ }
511+ }
512+ for i := 12; i < len(pk)-20; i++ {
513+ for _, x := range []byte{0x01, 0x80, 0xff} {
514+ m := bytes.Clone(pk)
515+ m[i] ^= x
516+ check(m)
517+ }
518+ }
519+ for n := 12; n < len(pk); n += 7 {
520+ check(pk[:n])
521+ }
522+ t.Logf("%d corrupted packs", cases)
523+}
524+
525+func readAll(rc interface{ Read([]byte) (int, error) }) ([]byte, error) {
526+ var out []byte
527+ buf := make([]byte, 4096)
528+ for {
529+ n, err := rc.Read(buf)
530+ out = append(out, buf[:n]...)
531+ if err != nil {
532+ if err.Error() == "EOF" {
533+ return out, nil
534+ }
535+ return out, err
536+ }
537+ }
538+}
@@ -0,0 +1,538 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "compress/zlib"
8+ "crypto/sha1"
9+ "encoding/binary"
10+ "errors"
11+ "fmt"
12+ "strings"
13+ "testing"
14+)
15+
16+func TestObjectID(t *testing.T) {
17+ const hex = "0123456789abcdef0123456789abcdef01234567"
18+ id, err := ParseID(hex)
19+ if err != nil || id.String() != hex || id.IsZero() {
20+ t.Fatalf("ParseID = %s %v", id, err)
21+ }
22+ if up, err := ParseID(strings.ToUpper(hex)); err != nil || up != id {
23+ t.Fatalf("upper case: %v", err)
24+ }
25+ for _, bad := range []string{"", "abc", hex[:39], hex + "0", strings.Repeat("g", 40), hex[:39] + "\x00"} {
26+ if _, err := ParseID(bad); !errors.Is(err, ErrInvalidID) {
27+ t.Errorf("ParseID(%q) = %v", bad, err)
28+ }
29+ }
30+ if !(ObjectID{}).IsZero() {
31+ t.Error("zero id not zero")
32+ }
33+ buf := make([]byte, 0, 64)
34+ out := id.AppendHex(append(buf, "x:"...))
35+ if string(out) != "x:"+hex {
36+ t.Errorf("AppendHex = %s", out)
37+ }
38+ if n := testing.AllocsPerRun(100, func() { _ = id.AppendHex(buf[:0]) }); n != 0 {
39+ t.Errorf("AppendHex allocates %v", n)
40+ }
41+}
42+
43+// varint encodes a delta header size.
44+func varint(v uint64) []byte {
45+ var b []byte
46+ for {
47+ c := byte(v & 0x7f)
48+ v >>= 7
49+ if v != 0 {
50+ b = append(b, c|0x80)
51+ } else {
52+ return append(b, c)
53+ }
54+ }
55+}
56+
57+func copyOp(off, size uint32) []byte {
58+ op := byte(0x80)
59+ var args []byte
60+ for i := 0; i < 4; i++ {
61+ if b := byte(off >> (8 * i)); b != 0 {
62+ op |= 1 << i
63+ args = append(args, b)
64+ }
65+ }
66+ for i := 0; i < 3; i++ {
67+ if b := byte(size >> (8 * i)); b != 0 {
68+ op |= 0x10 << i
69+ args = append(args, b)
70+ }
71+ }
72+ return append([]byte{op}, args...)
73+}
74+
75+func TestApplyDelta(t *testing.T) {
76+ base := []byte("hello, delta world")
77+ mk := func(baseSize, resSize uint64, ops ...[]byte) []byte {
78+ d := append(varint(baseSize), varint(resSize)...)
79+ for _, o := range ops {
80+ d = append(d, o...)
81+ }
82+ return d
83+ }
84+ ins := func(s string) []byte { return append([]byte{byte(len(s))}, s...) }
85+ good := mk(uint64(len(base)), 12, copyOp(0, 5), ins(" Go"), copyOp(11, 4))
86+ got, err := patch(base, good, 1<<20)
87+ if err != nil || string(got) != "hello Go"+string(base[11:15]) {
88+ t.Fatalf("patch = %q, %v", got, err)
89+ }
90+ // copy size 0 means 0x10000
91+ bigBase := bytes.Repeat([]byte{'x'}, 0x10000)
92+ got, err = patch(bigBase, mk(0x10000, 0x10000, []byte{0x80}), 1<<20)
93+ if err != nil || !bytes.Equal(got, bigBase) {
94+ t.Fatalf("copy 0x10000: %v", err)
95+ }
96+ bad := map[string][]byte{
97+ "empty": nil,
98+ "truncated header": {0x80},
99+ "base size": mk(3, 1, ins("a")),
100+ "copy outside": mk(uint64(len(base)), 4, copyOp(15, 4)),
101+ "copy huge": mk(uint64(len(base)), 4, copyOp(0xffffffff, 0xffffff)),
102+ "insert past end": mk(uint64(len(base)), 9, []byte{9, 'a'}),
103+ "result too long": mk(uint64(len(base)), 1, ins("ab")),
104+ "result too short": mk(uint64(len(base)), 5, ins("ab")),
105+ "opcode zero": mk(uint64(len(base)), 1, []byte{0}),
106+ "truncated copy": mk(uint64(len(base)), 1, []byte{0x81}),
107+ "huge varint": append(bytes.Repeat([]byte{0xff}, 12), 0x01),
108+ }
109+ for name, d := range bad {
110+ if _, err := patch(base, d, 1<<20); !errors.Is(err, ErrCorrupt) {
111+ t.Errorf("%s: %v, want ErrCorrupt", name, err)
112+ }
113+ }
114+ if _, err := patch(base, mk(uint64(len(base)), 1<<40, ins("a")), 1<<20); !errors.Is(err, ErrTooLarge) {
115+ t.Errorf("huge result: %v, want ErrTooLarge", err)
116+ }
117+}
118+
119+func TestParseEntryHeader(t *testing.T) {
120+ mk := func(body ...byte) []byte {
121+ d := append([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"), body...)
122+ return append(d, make([]byte, 20)...)
123+ }
124+ // blob, size 5
125+ h, err := parseEntryHeader(mk(0x35, 0x78, 0x9c), 12)
126+ if err != nil || h.typ != 3 || h.size != 5 || h.data != 13 {
127+ t.Fatalf("blob header = %+v %v", h, err)
128+ }
129+ // size 0x1234 : 4 low bits 4, then 7 bits ...
130+ h, err = parseEntryHeader(mk(0xb4, 0x23, 0x78), 12) // type 3 continued
131+ if err != nil || h.size != 4|0x23<<4 {
132+ t.Fatalf("multi byte size = %+v %v", h, err)
133+ }
134+ for name, d := range map[string][]byte{
135+ "unterminated": mk(bytes.Repeat([]byte{0xbf}, 20)...),
136+ "bad type 0": mk(0x05, 0x78),
137+ "bad type 5": mk(0x55, 0x78),
138+ "ofs no data": mk(0x65),
139+ "ofs outside": mk(0x65, 0x7f, 0x78),
140+ "ref truncated": mk(0x75, 1, 2, 3),
141+ } {
142+ off := uint64(12)
143+ if _, err := parseEntryHeader(d, off); !errors.Is(err, ErrCorrupt) {
144+ t.Errorf("%s: %v", name, err)
145+ }
146+ }
147+ if _, err := parseEntryHeader(mk(0x35, 0x78), 5); !errors.Is(err, ErrCorrupt) {
148+ t.Error("offset inside the pack header")
149+ }
150+ if _, err := parseEntryHeader(mk(0x35, 0x78), 1<<40); !errors.Is(err, ErrCorrupt) {
151+ t.Error("offset far outside")
152+ }
153+ if _, err := parseEntryHeader(nil, 12); !errors.Is(err, ErrCorrupt) {
154+ t.Error("nil pack")
155+ }
156+}
157+
158+func TestParseTreeErrors(t *testing.T) {
159+ good := append([]byte("100644 a\x00"), bytes.Repeat([]byte{1}, 20)...)
160+ good = append(good, append([]byte("40000 d\x00"), bytes.Repeat([]byte{2}, 20)...)...)
161+ n := 0
162+ if err := ParseTree(good, func(e Entry) bool { n++; return true }); err != nil || n != 2 {
163+ t.Fatalf("good tree: %d %v", n, err)
164+ }
165+ n = 0
166+ if err := ParseTree(good, func(e Entry) bool { n++; return false }); err != nil || n != 1 {
167+ t.Fatalf("early stop: %d %v", n, err)
168+ }
169+ for name, d := range map[string][]byte{
170+ "no space": []byte("100644"),
171+ "mode letters": []byte("10x644 a\x00" + strings.Repeat("a", 20)),
172+ "empty mode": []byte(" a\x00" + strings.Repeat("a", 20)),
173+ "long mode": []byte("10000000 a\x00" + strings.Repeat("a", 20)),
174+ "no nul": []byte("100644 abc"),
175+ "short id": []byte("100644 a\x00" + strings.Repeat("a", 19)),
176+ "empty name": []byte("100644 \x00" + strings.Repeat("a", 20)),
177+ } {
178+ if err := ParseTree(d, func(Entry) bool { return true }); !errors.Is(err, ErrCorrupt) {
179+ t.Errorf("%s: %v", name, err)
180+ }
181+ }
182+ if err := ParseTree(nil, func(Entry) bool { t.Fatal("called"); return true }); err != nil {
183+ t.Errorf("empty tree: %v", err)
184+ }
185+}
186+
187+func TestParseCommitEdgeCases(t *testing.T) {
188+ tree := strings.Repeat("a", 40)
189+ par := strings.Repeat("b", 40)
190+ var c Commit
191+ // No message at all, no trailing newline.
192+ if err := ParseCommit([]byte("tree "+tree+"\nauthor A <a@x> 1 +0000\ncommitter B <b@x> 2 -0130"), &c); err != nil {
193+ t.Fatal(err)
194+ }
195+ if c.Message != nil || c.Committer.Offset != -(3600+30*60) || len(c.Parents) != 0 {
196+ t.Errorf("commit = %+v", c)
197+ }
198+ // Two parents then reuse with zero parents keeps no stale state.
199+ if err := ParseCommit([]byte("tree "+tree+"\nparent "+par+"\nparent "+tree+"\nauthor A <a@x> 1 +0000\n\nmsg\n"), &c); err != nil || len(c.Parents) != 2 || string(c.Message) != "msg\n" {
200+ t.Fatalf("two parents: %+v %v", c, err)
201+ }
202+ if err := ParseCommit([]byte("tree "+tree+"\n\n"), &c); err != nil || len(c.Parents) != 0 || c.Author.Name != nil || len(c.Message) != 0 {
203+ t.Fatalf("reuse: %+v %v", c, err)
204+ }
205+ // Multi-line header continuation containing something that looks like a header.
206+ raw := "tree " + tree + "\nmergetag object x\n tag y\n parent " + par + "\n\n \nauthor A <a@x> 1 +0000\ncommitter C <c@x> 5 +0100\n\nbody"
207+ if err := ParseCommit([]byte(raw), &c); err != nil || len(c.Parents) != 0 {
208+ t.Fatalf("continuation: %+v %v", c, err)
209+ }
210+ // Odd signatures.
211+ for in, want := range map[string]Signature{
212+ "Name <e> 12 +0200": {Name: []byte("Name"), Email: []byte("e"), Time: 12, Offset: 7200},
213+ "<> 0 +0000": {Name: []byte{}, Email: []byte{}},
214+ "No Email 1 +0000": {Name: []byte("No Email 1 +0000")},
215+ "A <b> notatime +0100": {Name: []byte("A"), Email: []byte("b")},
216+ "A <b> 5 +01": {Name: []byte("A"), Email: []byte("b"), Time: 5},
217+ "A <b <c> 7 -0000": {Name: []byte("A"), Email: []byte("b <c"), Time: 7},
218+ "A <b> 99999999999999999999 +0000": {Name: []byte("A"), Email: []byte("b")},
219+ } {
220+ got := parseSignature([]byte(in))
221+ if !bytes.Equal(got.Name, want.Name) || !bytes.Equal(got.Email, want.Email) || got.Time != want.Time || got.Offset != want.Offset {
222+ t.Errorf("parseSignature(%q) = %+v %q %q, want %+v", in, got, got.Name, got.Email, want)
223+ }
224+ }
225+ for name, d := range map[string]string{
226+ "empty": "",
227+ "no tree": "author A <a@x> 1 +0000\n\nmsg",
228+ "bad tree": "tree xyz\n\n",
229+ "short tree": "tree " + tree[:39] + "\n\n",
230+ "bad parent": "tree " + tree + "\nparent zz\n\n",
231+ "double tree": "tree " + tree + "\ntree " + tree + "\n\n",
232+ } {
233+ if err := ParseCommit([]byte(d), &c); !errors.Is(err, ErrCorrupt) {
234+ t.Errorf("%s: %v", name, err)
235+ }
236+ }
237+}
238+
239+func TestParseTagErrors(t *testing.T) {
240+ obj := strings.Repeat("c", 40)
241+ var tg Tag
242+ if err := ParseTag([]byte("object "+obj+"\ntype commit\ntag v1\ntagger T <t@x> 3 +0000\n\nmsg"), &tg); err != nil || tg.Type != TypeCommit || string(tg.Name) != "v1" || string(tg.Message) != "msg" {
243+ t.Fatalf("tag = %+v %v", tg, err)
244+ }
245+ for name, d := range map[string]string{
246+ "empty": "",
247+ "no type": "object " + obj + "\n",
248+ "no obj": "type commit\n",
249+ "bad type": "object " + obj + "\ntype thing\n",
250+ "bad obj": "object zz\ntype commit\n",
251+ } {
252+ if err := ParseTag([]byte(d), &tg); !errors.Is(err, ErrCorrupt) {
253+ t.Errorf("%s: %v", name, err)
254+ }
255+ }
256+}
257+
258+func TestParsePackedRefsFormats(t *testing.T) {
259+ a, b, c := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40)
260+ p, err := parsePackedRefs("# pack-refs with: peeled fully-peeled sorted \n" + a + " refs/heads/main\n" + b + " refs/tags/v1\n^" + c + "\n" + c + " refs/tags/v2\n")
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ if e, _ := p.find("refs/tags/v1"); !e.peeledKnown || e.peeled.String() != c {
265+ t.Errorf("v1 = %+v", e)
266+ }
267+ if e, _ := p.find("refs/heads/main"); !e.peeledKnown || e.peeled.String() != a {
268+ t.Errorf("fully peeled: main = %+v", e)
269+ }
270+ // Without the header nothing is known.
271+ p, err = parsePackedRefs(a + " refs/tags/v1\n")
272+ if err != nil {
273+ t.Fatal(err)
274+ }
275+ if e, _ := p.find("refs/tags/v1"); e.peeledKnown {
276+ t.Errorf("unknown peel claimed known")
277+ }
278+ // Old "peeled" trait: tags known, branches not.
279+ p, _ = parsePackedRefs("# pack-refs with: peeled\n" + a + " refs/heads/x\n" + a + " refs/tags/t\n")
280+ if e, _ := p.find("refs/heads/x"); e.peeledKnown {
281+ t.Error("branch peel known with the peeled trait")
282+ }
283+ if e, _ := p.find("refs/tags/t"); !e.peeledKnown {
284+ t.Error("tag peel unknown with the peeled trait")
285+ }
286+ for _, bad := range []string{"^" + a + "\n", a + "\n", "zz refs/x\n", a + " \n", a + " refs/x\n^zz\n"} {
287+ if _, err := parsePackedRefs(bad); !errors.Is(err, ErrCorrupt) {
288+ t.Errorf("parsePackedRefs(%q) = %v", bad, err)
289+ }
290+ }
291+}
292+
293+func TestValidRefName(t *testing.T) {
294+ for _, ok := range []string{"refs/heads/main", "refs/heads/a/b", "refs/tags/v1.0", "refs/heads/é"} {
295+ if !validRefName(ok) {
296+ t.Errorf("%q rejected", ok)
297+ }
298+ }
299+ for _, bad := range []string{"", "HEAD", "refs", "refs/", "/refs/heads/x", "refs/heads/x/", "refs/heads/../x", "refs/heads/a..b", "refs/heads/.x", "refs/heads/x.lock",
300+ "refs/heads//x", "refs/heads/a b", "refs/heads/a~1", "refs/heads/a^", "refs/heads/a:b", "refs/heads/a?", "refs/heads/a*", "refs/heads/a[", "refs/heads/a\\b", "refs/heads/a@{1}", "refs/heads/\x01"} {
301+ if validRefName(bad) {
302+ t.Errorf("%q accepted", bad)
303+ }
304+ }
305+}
306+
307+// zlibBytes compresses b.
308+func zlibBytes(b []byte) []byte {
309+ var out bytes.Buffer
310+ w := zlib.NewWriter(&out)
311+ _, _ = w.Write(b)
312+ _ = w.Close()
313+ return out.Bytes()
314+}
315+
316+// entryBytes encodes a pack entry header followed by data.
317+func entryBytes(typ int, size int, extra []byte, data []byte) []byte {
318+ c := byte(typ<<4) | byte(size&0x0f)
319+ size >>= 4
320+ var out []byte
321+ for size > 0 {
322+ out = append(out, c|0x80)
323+ c = byte(size & 0x7f)
324+ size >>= 7
325+ }
326+ out = append(out, c)
327+ out = append(out, extra...)
328+ return append(out, zlibBytes(data)...)
329+}
330+
331+// buildPack assembles a pack file and its v2 index from entries (whose ids
332+// are given) and returns both.
333+func buildPack(entries [][]byte, ids []ObjectID, offs *[]uint64) (pack, idx []byte) {
334+ pack = append([]byte("PACK"), 0, 0, 0, 2, 0, 0, 0, byte(len(entries)))
335+ for _, e := range entries {
336+ *offs = append(*offs, uint64(len(pack)))
337+ pack = append(pack, e...)
338+ }
339+ sum := sha1.Sum(pack)
340+ pack = append(pack, sum[:]...)
341+
342+ order := make([]int, len(ids))
343+ for i := range order {
344+ order[i] = i
345+ }
346+ for i := range order { // insertion sort by id
347+ for j := i; j > 0 && bytes.Compare(ids[order[j]][:], ids[order[j-1]][:]) < 0; j-- {
348+ order[j], order[j-1] = order[j-1], order[j]
349+ }
350+ }
351+ idx = append([]byte{0xff, 't', 'O', 'c', 0, 0, 0, 2}, make([]byte, 1024)...)
352+ for i, k := range order {
353+ for b := int(ids[k][0]); b < 256; b++ {
354+ cur := binary.BigEndian.Uint32(idx[8+4*b:])
355+ if cur < uint32(i+1) {
356+ binary.BigEndian.PutUint32(idx[8+4*b:], uint32(i+1))
357+ }
358+ }
359+ }
360+ for _, k := range order {
361+ idx = append(idx, ids[k][:]...)
362+ }
363+ idx = append(idx, make([]byte, 4*len(order))...) // crc
364+ for _, k := range order {
365+ idx = binary.BigEndian.AppendUint32(idx, uint32((*offs)[k]))
366+ }
367+ idx = append(idx, pack[len(pack)-20:]...)
368+ isum := sha1.Sum(idx)
369+ return pack, append(idx, isum[:]...)
370+}
371+
372+func objectIDOf(typ string, data []byte) ObjectID {
373+ h := sha1.New()
374+ _, _ = fmt.Fprintf(h, "%s %d\x00", typ, len(data))
375+ _, _ = h.Write(data)
376+ var id ObjectID
377+ copy(id[:], h.Sum(nil))
378+ return id
379+}
380+
381+func TestParseIdxErrors(t *testing.T) {
382+ var offs []uint64
383+ blob := []byte("hello")
384+ _, idx := buildPack([][]byte{entryBytes(3, len(blob), nil, blob)}, []ObjectID{objectIDOf("blob", blob)}, &offs)
385+ x, err := parseIdx(idx)
386+ if err != nil || x.n != 1 {
387+ t.Fatalf("good idx: %v", err)
388+ }
389+ if _, ok := x.find(&ObjectID{}); ok {
390+ t.Error("found a missing id")
391+ }
392+ id := objectIDOf("blob", blob)
393+ if pos, ok := x.find(&id); !ok || pos != 0 {
394+ t.Error("id not found")
395+ }
396+ trunc := func(n int) []byte { return idx[:n] }
397+ mut := func(f func(b []byte)) []byte { b := bytes.Clone(idx); f(b); return b }
398+ for name, d := range map[string][]byte{
399+ "empty": nil,
400+ "short": trunc(100),
401+ "no trailer": trunc(len(idx) - 41),
402+ "magic": mut(func(b []byte) { b[0] = 0 }),
403+ "version 3": mut(func(b []byte) { b[7] = 3 }),
404+ "fanout down": mut(func(b []byte) { binary.BigEndian.PutUint32(b[8+4*10:], 5) }),
405+ "n too big": mut(func(b []byte) { binary.BigEndian.PutUint32(b[8+4*255:], 1<<31) }),
406+ "odd large part": append(bytes.Clone(idx), 1, 2, 3),
407+ } {
408+ if _, err := parseIdx(d); err == nil {
409+ t.Errorf("%s: accepted", name)
410+ }
411+ }
412+ // A large offset entry pointing outside of the table.
413+ d := mut(func(b []byte) {
414+ off := len(b) - 40 - 4
415+ binary.BigEndian.PutUint32(b[off:], 0x80000005)
416+ })
417+ x, err = parseIdx(d)
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if _, err := x.offset(0); !errors.Is(err, ErrCorrupt) {
422+ t.Errorf("large offset outside table: %v", err)
423+ }
424+}
425+
426+// TestCorruptPacks mutates a real pack on disk and checks that every read
427+// either succeeds with the right content or fails with ErrCorrupt, never
428+// panics.
429+func TestCorruptPacks(t *testing.T) {
430+ // A small hand built pack: a blob, a delta against it, a ref-delta.
431+ base := bytes.Repeat([]byte("0123456789abcdef"), 50)
432+ res := append(bytes.Clone(base[:400]), []byte("tail")...)
433+ delta := append(varint(uint64(len(base))), varint(uint64(len(res)))...)
434+ delta = append(delta, copyOp(0, 400)...)
435+ delta = append(delta, 4, 't', 'a', 'i', 'l')
436+ baseID, resID := objectIDOf("blob", base), objectIDOf("blob", res)
437+
438+ var offs []uint64
439+ e0 := entryBytes(3, len(base), nil, base)
440+ // ofs-delta distance to entry 0 is len(e0)
441+ ofs := []byte{byte(len(e0))}
442+ if len(e0) >= 128 {
443+ t.Fatal("fixture: e0 too long for a one byte offset")
444+ }
445+ e1 := entryBytes(objOfsDelta, len(delta), ofs, delta)
446+ res2 := append(bytes.Clone(base[:400]), []byte("TAIL")...)
447+ delta2 := append(varint(uint64(len(base))), varint(uint64(len(res2)))...)
448+ delta2 = append(delta2, copyOp(0, 400)...)
449+ delta2 = append(delta2, 4, 'T', 'A', 'I', 'L')
450+ e2 := entryBytes(objRefDelta, len(delta2), baseID[:], delta2)
451+ res2ID := objectIDOf("blob", res2)
452+ pk, idx := buildPack([][]byte{e0, e1, e2}, []ObjectID{baseID, resID, res2ID}, &offs)
453+
454+ dir := t.TempDir()
455+ write := func(pack, idxd []byte) *Repo {
456+ t.Helper()
457+ g := dir + "/g" + fmt.Sprint(len(pack)) + fmt.Sprint(len(idxd)) + fmt.Sprint(bytes.Count(pack, []byte{0}))
458+ writeFile(t, g+"/HEAD", "ref: refs/heads/main\n")
459+ writeFile(t, g+"/refs/.keep", "")
460+ name := fmt.Sprintf("pack-%x", pk[len(pk)-20:])
461+ writeFile(t, g+"/objects/pack/"+name+".pack", string(pack))
462+ writeFile(t, g+"/objects/pack/"+name+".idx", string(idxd))
463+ r, err := Open(g)
464+ if err != nil {
465+ return nil
466+ }
467+ t.Cleanup(func() { _ = r.Close() })
468+ return r
469+ }
470+ // Intact.
471+ r := write(pk, idx)
472+ if r == nil {
473+ t.Fatal("intact pack rejected")
474+ }
475+ for id, want := range map[ObjectID][]byte{baseID: base, resID: res, res2ID: res2} {
476+ _, got, err := r.ReadObject(id, nil)
477+ if err != nil || !bytes.Equal(got, want) {
478+ t.Fatalf("intact %s: %v", id, err)
479+ }
480+ if _, n, err := r.ObjectHeader(id); err != nil || n != int64(len(want)) {
481+ t.Fatalf("intact header %s: %d %v", id, n, err)
482+ }
483+ }
484+ // Every single byte flipped in the pack body and truncations.
485+ cases := 0
486+ check := func(pack []byte) {
487+ cases++
488+ r := write(pack, idx)
489+ if r == nil {
490+ return // rejected at Open: fine
491+ }
492+ for _, id := range []ObjectID{baseID, resID, res2ID} {
493+ _, _, err := r.ReadObject(id, nil)
494+ if err != nil && !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) {
495+ t.Fatalf("ReadObject: %v is not ErrCorrupt", err)
496+ }
497+ _, _, err = r.ObjectHeader(id)
498+ if err != nil && !errors.Is(err, ErrCorrupt) {
499+ t.Fatalf("ObjectHeader: %v is not ErrCorrupt", err)
500+ }
501+ if _, rc, err := r.OpenBlob(id); err == nil {
502+ _, rerr := readAll(rc)
503+ _ = rc.Close()
504+ if rerr != nil && !errors.Is(rerr, ErrCorrupt) {
505+ t.Fatalf("blob read: %v is not ErrCorrupt", rerr)
506+ }
507+ } else if !errors.Is(err, ErrCorrupt) && !errors.Is(err, ErrTooLarge) && !errors.Is(err, ErrWrongType) {
508+ t.Fatalf("OpenBlob: %v", err)
509+ }
510+ }
511+ }
512+ for i := 12; i < len(pk)-20; i++ {
513+ for _, x := range []byte{0x01, 0x80, 0xff} {
514+ m := bytes.Clone(pk)
515+ m[i] ^= x
516+ check(m)
517+ }
518+ }
519+ for n := 12; n < len(pk); n += 7 {
520+ check(pk[:n])
521+ }
522+ t.Logf("%d corrupted packs", cases)
523+}
524+
525+func readAll(rc interface{ Read([]byte) (int, error) }) ([]byte, error) {
526+ var out []byte
527+ buf := make([]byte, 4096)
528+ for {
529+ n, err := rc.Read(buf)
530+ out = append(out, buf[:n]...)
531+ if err != nil {
532+ if err.Error() == "EOF" {
533+ return out, nil
534+ }
535+ return out, err
536+ }
537+ }
538+}
A race_off_test.go +7 -0
@@ -0,0 +1,7 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build !race
4+
5+package git
6+
7+const raceEnabled = false
@@ -0,0 +1,7 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build !race
4+
5+package git
6+
7+const raceEnabled = false
A race_on_test.go +9 -0
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build race
4+
5+package git
6+
7+// raceEnabled is true under the race detector, which makes sync.Pool drop
8+// items at random and so changes allocation counts.
9+const raceEnabled = true
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build race
4+
5+package git
6+
7+// raceEnabled is true under the race detector, which makes sync.Pool drop
8+// items at random and so changes allocation counts.
9+const raceEnabled = true
A realrepo_test.go +137 -0
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "errors"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "testing"
15+)
16+
17+// TestRealRepository walks every object of the repository named by
18+// ERMITE_GIT_BENCH_REPO and compares it with git cat-file --batch.
19+func TestRealRepository(t *testing.T) {
20+ dir := os.Getenv("ERMITE_GIT_BENCH_REPO")
21+ if dir == "" {
22+ t.Skip("ERMITE_GIT_BENCH_REPO is not set")
23+ }
24+ requireGit(t)
25+ r, err := Open(dir)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ defer func() { _ = r.Close() }()
30+
31+ cmd := exec.Command("git", "-C", dir, "cat-file", "--batch-all-objects", "--batch")
32+ cmd.Env = gitEnv()
33+ stdout, err := cmd.StdoutPipe()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := cmd.Start(); err != nil {
38+ t.Fatal(err)
39+ }
40+ br := bufio.NewReaderSize(stdout, 1<<20)
41+ var buf, want []byte
42+ counts := map[Type]int{}
43+ n := 0
44+ for {
45+ hdr, err := br.ReadString('\n')
46+ if errors.Is(err, io.EOF) {
47+ break
48+ }
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ f := strings.Fields(hdr)
53+ size, _ := strconv.Atoi(f[2])
54+ want = grow(want, size+1)
55+ if _, err := io.ReadFull(br, want); err != nil {
56+ t.Fatal(err)
57+ }
58+ want = want[:size]
59+ id := mustID(t, f[0])
60+ typ, got, err := r.ReadObject(id, buf)
61+ if err != nil {
62+ t.Fatalf("ReadObject(%s): %v", id, err)
63+ }
64+ buf = got
65+ if typ.String() != f[1] || !bytes.Equal(got, want) {
66+ t.Fatalf("object %s differs: %s/%d vs %s/%d", id, typ, len(got), f[1], size)
67+ }
68+ htyp, hsize, err := r.ObjectHeader(id)
69+ if err != nil || htyp != typ || hsize != int64(size) {
70+ t.Fatalf("ObjectHeader(%s) = %s %d %v, want %s %d", id, htyp, hsize, err, f[1], size)
71+ }
72+ if typ == TypeBlob && n%7 == 0 {
73+ bsize, rc, err := r.OpenBlob(id)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ data, err := io.ReadAll(rc)
78+ _ = rc.Close()
79+ if err != nil || bsize != int64(size) || !bytes.Equal(data, want) {
80+ t.Fatalf("OpenBlob(%s) differs: %v", id, err)
81+ }
82+ }
83+ if typ == TypeCommit {
84+ var c Commit
85+ if err := ParseCommit(got, &c); err != nil {
86+ t.Fatalf("ParseCommit(%s): %v", id, err)
87+ }
88+ }
89+ if typ == TypeTree {
90+ if err := ParseTree(got, func(Entry) bool { return true }); err != nil {
91+ t.Fatalf("ParseTree(%s): %v", id, err)
92+ }
93+ }
94+ counts[typ]++
95+ n++
96+ }
97+ if err := cmd.Wait(); err != nil {
98+ t.Fatal(err)
99+ }
100+ t.Logf("%d objects identical to git cat-file: %v", n, counts)
101+ if n == 0 {
102+ t.Fatal("no object")
103+ }
104+
105+ // Refs and HEAD against git.
106+ checkRefsAgainstGit(t, r, dir)
107+}
108+
109+func checkRefsAgainstGit(t *testing.T, r *Repo, dir string) {
110+ t.Helper()
111+ out, err := exec.Command("git", "-C", dir, "for-each-ref", "--format=%(refname) %(objectname) %(*objectname)").Output()
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ var want, got []string
116+ for _, l := range lines(string(out)) {
117+ want = append(want, strings.TrimRight(l, " "))
118+ }
119+ for ref, err := range r.Refs("") {
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ peeled := ""
124+ if typ, _, err := r.ObjectHeader(ref.ID); err == nil && typ == TypeTag {
125+ p, _, err := r.Peel(ref.ID)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ peeled = p.String()
130+ }
131+ got = append(got, strings.TrimRight(ref.Name+" "+ref.ID.String()+" "+peeled, " "))
132+ }
133+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
134+ t.Fatalf("refs differ:\n%d got vs %d want", len(got), len(want))
135+ }
136+ t.Logf("%d refs identical to git for-each-ref", len(got))
137+}
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "errors"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "testing"
15+)
16+
17+// TestRealRepository walks every object of the repository named by
18+// ERMITE_GIT_BENCH_REPO and compares it with git cat-file --batch.
19+func TestRealRepository(t *testing.T) {
20+ dir := os.Getenv("ERMITE_GIT_BENCH_REPO")
21+ if dir == "" {
22+ t.Skip("ERMITE_GIT_BENCH_REPO is not set")
23+ }
24+ requireGit(t)
25+ r, err := Open(dir)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ defer func() { _ = r.Close() }()
30+
31+ cmd := exec.Command("git", "-C", dir, "cat-file", "--batch-all-objects", "--batch")
32+ cmd.Env = gitEnv()
33+ stdout, err := cmd.StdoutPipe()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := cmd.Start(); err != nil {
38+ t.Fatal(err)
39+ }
40+ br := bufio.NewReaderSize(stdout, 1<<20)
41+ var buf, want []byte
42+ counts := map[Type]int{}
43+ n := 0
44+ for {
45+ hdr, err := br.ReadString('\n')
46+ if errors.Is(err, io.EOF) {
47+ break
48+ }
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ f := strings.Fields(hdr)
53+ size, _ := strconv.Atoi(f[2])
54+ want = grow(want, size+1)
55+ if _, err := io.ReadFull(br, want); err != nil {
56+ t.Fatal(err)
57+ }
58+ want = want[:size]
59+ id := mustID(t, f[0])
60+ typ, got, err := r.ReadObject(id, buf)
61+ if err != nil {
62+ t.Fatalf("ReadObject(%s): %v", id, err)
63+ }
64+ buf = got
65+ if typ.String() != f[1] || !bytes.Equal(got, want) {
66+ t.Fatalf("object %s differs: %s/%d vs %s/%d", id, typ, len(got), f[1], size)
67+ }
68+ htyp, hsize, err := r.ObjectHeader(id)
69+ if err != nil || htyp != typ || hsize != int64(size) {
70+ t.Fatalf("ObjectHeader(%s) = %s %d %v, want %s %d", id, htyp, hsize, err, f[1], size)
71+ }
72+ if typ == TypeBlob && n%7 == 0 {
73+ bsize, rc, err := r.OpenBlob(id)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ data, err := io.ReadAll(rc)
78+ _ = rc.Close()
79+ if err != nil || bsize != int64(size) || !bytes.Equal(data, want) {
80+ t.Fatalf("OpenBlob(%s) differs: %v", id, err)
81+ }
82+ }
83+ if typ == TypeCommit {
84+ var c Commit
85+ if err := ParseCommit(got, &c); err != nil {
86+ t.Fatalf("ParseCommit(%s): %v", id, err)
87+ }
88+ }
89+ if typ == TypeTree {
90+ if err := ParseTree(got, func(Entry) bool { return true }); err != nil {
91+ t.Fatalf("ParseTree(%s): %v", id, err)
92+ }
93+ }
94+ counts[typ]++
95+ n++
96+ }
97+ if err := cmd.Wait(); err != nil {
98+ t.Fatal(err)
99+ }
100+ t.Logf("%d objects identical to git cat-file: %v", n, counts)
101+ if n == 0 {
102+ t.Fatal("no object")
103+ }
104+
105+ // Refs and HEAD against git.
106+ checkRefsAgainstGit(t, r, dir)
107+}
108+
109+func checkRefsAgainstGit(t *testing.T, r *Repo, dir string) {
110+ t.Helper()
111+ out, err := exec.Command("git", "-C", dir, "for-each-ref", "--format=%(refname) %(objectname) %(*objectname)").Output()
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ var want, got []string
116+ for _, l := range lines(string(out)) {
117+ want = append(want, strings.TrimRight(l, " "))
118+ }
119+ for ref, err := range r.Refs("") {
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ peeled := ""
124+ if typ, _, err := r.ObjectHeader(ref.ID); err == nil && typ == TypeTag {
125+ p, _, err := r.Peel(ref.ID)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ peeled = p.String()
130+ }
131+ got = append(got, strings.TrimRight(ref.Name+" "+ref.ID.String()+" "+peeled, " "))
132+ }
133+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
134+ t.Fatalf("refs differ:\n%d got vs %d want", len(got), len(want))
135+ }
136+ t.Logf("%d refs identical to git for-each-ref", len(got))
137+}
A stress_test.go +217 -0
@@ -0,0 +1,217 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+)
15+
16+// TestRefreshDuringRepack runs readers while git repacks (deleting the packs
17+// they read from), prunes and packs refs, and the Repo is refreshed. No read
18+// may fail, and every read must return the right content.
19+func TestRefreshDuringRepack(t *testing.T) {
20+ f := newFx(t)
21+ var ids []ObjectID
22+ var blobs [][]byte
23+ for i := 0; i < 40; i++ {
24+ data := append(textLines(500, i%3), []byte(fmt.Sprintf("rev %d\n", i))...)
25+ f.write("f.txt", data, 0o644)
26+ f.write(fmt.Sprintf("d/%d.txt", i), []byte(fmt.Sprint(i)), 0o644)
27+ f.commit(fmt.Sprintf("c%d", i))
28+ ids = append(ids, mustID(t, f.git("rev-parse", "HEAD:f.txt")))
29+ blobs = append(blobs, data)
30+ }
31+ f.git("repack", "-adf", "--depth=50", "--window=50")
32+ r := f.open(WithDeltaCacheSize(1 << 20))
33+
34+ stop := make(chan struct{})
35+ var wg sync.WaitGroup
36+ var reads atomic.Int64
37+ var failures atomic.Int64
38+ fail := func(format string, a ...any) {
39+ failures.Add(1)
40+ t.Errorf(format, a...)
41+ }
42+ for w := 0; w < 6; w++ {
43+ wg.Add(1)
44+ go func(w int) {
45+ defer wg.Done()
46+ var buf []byte
47+ for i := w; ; i++ {
48+ select {
49+ case <-stop:
50+ return
51+ default:
52+ }
53+ k := i % len(ids)
54+ switch i % 4 {
55+ case 0:
56+ typ, got, err := r.ReadObject(ids[k], buf)
57+ if err != nil || typ != TypeBlob || !bytes.Equal(got, blobs[k]) {
58+ fail("ReadObject: %v", err)
59+ return
60+ }
61+ buf = got
62+ case 1:
63+ size, rc, err := r.OpenBlob(ids[k])
64+ if err != nil {
65+ fail("OpenBlob: %v", err)
66+ return
67+ }
68+ // Read slowly so the repack happens meanwhile.
69+ got, err := io.ReadAll(io.LimitReader(rc, 1<<20))
70+ _ = rc.Close()
71+ if err != nil || size != int64(len(blobs[k])) || !bytes.Equal(got, blobs[k]) {
72+ fail("blob read: %v", err)
73+ return
74+ }
75+ case 2:
76+ if _, n, err := r.ObjectHeader(ids[k]); err != nil || n != int64(len(blobs[k])) {
77+ fail("ObjectHeader: %v", err)
78+ return
79+ }
80+ case 3:
81+ if _, err := r.ResolvePrefix(ids[k].String()[:12]); err != nil && !errors.Is(err, ErrAmbiguous) {
82+ fail("ResolvePrefix: %v", err)
83+ return
84+ }
85+ for ref, err := range r.Refs("refs/") {
86+ if err != nil {
87+ fail("Refs: %v", err)
88+ return
89+ }
90+ if ref.Name == "refs/heads/main" && ref.ID.IsZero() {
91+ fail("main is zero")
92+ return
93+ }
94+ }
95+ }
96+ reads.Add(1)
97+ }
98+ }(w)
99+ }
100+ // Refresher.
101+ wg.Add(1)
102+ go func() {
103+ defer wg.Done()
104+ for {
105+ select {
106+ case <-stop:
107+ return
108+ default:
109+ }
110+ if err := r.Refresh(); err != nil {
111+ fail("Refresh: %v", err)
112+ return
113+ }
114+ time.Sleep(time.Millisecond)
115+ }
116+ }()
117+
118+ // The writer: repack -ad (deletes the packs), new commits, pack-refs, prune.
119+ for round := 0; round < 12 && failures.Load() == 0; round++ {
120+ f.write("w.txt", []byte(fmt.Sprint(round)), 0o644)
121+ f.commit(fmt.Sprintf("w%d", round))
122+ f.git("tag", fmt.Sprintf("t%d", round))
123+ switch round % 3 {
124+ case 0:
125+ f.git("repack", "-ad")
126+ case 1:
127+ f.git("repack", "-adf", "--depth=20", "--window=20")
128+ f.git("pack-refs", "--all")
129+ case 2:
130+ f.git("gc", "--prune=now", "-q")
131+ }
132+ }
133+ close(stop)
134+ wg.Wait()
135+ if failures.Load() != 0 {
136+ t.FailNow()
137+ }
138+ if err := r.Refresh(); err != nil {
139+ t.Fatal(err)
140+ }
141+ t.Logf("%d reads during 12 repack rounds", reads.Load())
142+ compareRefs(t, r, f.root())
143+ compareObjects(t, r, f.root())
144+ // Packs that were replaced are unmapped once no reader needs them.
145+ if n := len(r.snap.Load().packs); n != 1 {
146+ t.Errorf("%d packs left in the snapshot", n)
147+ }
148+}
149+
150+// A reader blocked on an old pack keeps it mapped until it is closed.
151+func TestOldPackStaysMappedWhileInUse(t *testing.T) {
152+ f := newFx(t)
153+ big := textLines(100000, 1)
154+ f.write("big", big, 0o644)
155+ f.commit("one")
156+ f.git("repack", "-adf")
157+ r := f.open()
158+ id := mustID(t, f.git("rev-parse", "HEAD:big"))
159+ _, rc, err := r.OpenBlob(id)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ old := r.snap.Load().packs[0]
164+ buf := make([]byte, 1000)
165+ if _, err := io.ReadFull(rc, buf); err != nil {
166+ t.Fatal(err)
167+ }
168+ f.write("more", []byte("more"), 0o644)
169+ f.commit("two")
170+ f.git("repack", "-adf") // deletes the pack that rc reads from
171+ if err := r.Refresh(); err != nil {
172+ t.Fatal(err)
173+ }
174+ if r.snap.Load().packs[0] == old {
175+ t.Fatal("the pack list did not change")
176+ }
177+ if old.data == nil || old.refs.Load() != 1 {
178+ t.Fatalf("old pack released while in use (refs=%d)", old.refs.Load())
179+ }
180+ rest, err := io.ReadAll(rc)
181+ if err != nil || !bytes.Equal(append(buf, rest...), big) {
182+ t.Fatalf("read after repack: %v", err)
183+ }
184+ _ = rc.Close()
185+ if old.data != nil {
186+ t.Fatal("old pack still mapped after the last reader closed")
187+ }
188+}
189+
190+// A ref-delta cycle must not loop forever.
191+func TestRefDeltaCycle(t *testing.T) {
192+ // Two ref-deltas that are each other's base.
193+ idA, idB := ObjectID{0x10}, ObjectID{0x20}
194+ body := append(varint(4), varint(4)...)
195+ body = append(body, 4, 'a', 'b', 'c', 'd')
196+ ea := entryBytes(objRefDelta, len(body), idB[:], body)
197+ eb := entryBytes(objRefDelta, len(body), idA[:], body)
198+ var offs []uint64
199+ pk, idx := buildPack([][]byte{ea, eb}, []ObjectID{idA, idB}, &offs)
200+ g := t.TempDir()
201+ writeFile(t, g+"/HEAD", "ref: refs/heads/main\n")
202+ writeFile(t, g+"/refs/.keep", "")
203+ name := fmt.Sprintf("pack-%x", pk[len(pk)-20:])
204+ writeFile(t, g+"/objects/pack/"+name+".pack", string(pk))
205+ writeFile(t, g+"/objects/pack/"+name+".idx", string(idx))
206+ r, err := Open(g)
207+ if err != nil {
208+ t.Fatal(err)
209+ }
210+ defer func() { _ = r.Close() }()
211+ if _, _, err := r.ReadObject(idA, nil); !errors.Is(err, ErrCorrupt) {
212+ t.Fatalf("ReadObject = %v, want ErrCorrupt", err)
213+ }
214+ if _, _, err := r.ObjectHeader(idA); !errors.Is(err, ErrCorrupt) {
215+ t.Fatalf("ObjectHeader = %v, want ErrCorrupt", err)
216+ }
217+}
@@ -0,0 +1,217 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+)
15+
16+// TestRefreshDuringRepack runs readers while git repacks (deleting the packs
17+// they read from), prunes and packs refs, and the Repo is refreshed. No read
18+// may fail, and every read must return the right content.
19+func TestRefreshDuringRepack(t *testing.T) {
20+ f := newFx(t)
21+ var ids []ObjectID
22+ var blobs [][]byte
23+ for i := 0; i < 40; i++ {
24+ data := append(textLines(500, i%3), []byte(fmt.Sprintf("rev %d\n", i))...)
25+ f.write("f.txt", data, 0o644)
26+ f.write(fmt.Sprintf("d/%d.txt", i), []byte(fmt.Sprint(i)), 0o644)
27+ f.commit(fmt.Sprintf("c%d", i))
28+ ids = append(ids, mustID(t, f.git("rev-parse", "HEAD:f.txt")))
29+ blobs = append(blobs, data)
30+ }
31+ f.git("repack", "-adf", "--depth=50", "--window=50")
32+ r := f.open(WithDeltaCacheSize(1 << 20))
33+
34+ stop := make(chan struct{})
35+ var wg sync.WaitGroup
36+ var reads atomic.Int64
37+ var failures atomic.Int64
38+ fail := func(format string, a ...any) {
39+ failures.Add(1)
40+ t.Errorf(format, a...)
41+ }
42+ for w := 0; w < 6; w++ {
43+ wg.Add(1)
44+ go func(w int) {
45+ defer wg.Done()
46+ var buf []byte
47+ for i := w; ; i++ {
48+ select {
49+ case <-stop:
50+ return
51+ default:
52+ }
53+ k := i % len(ids)
54+ switch i % 4 {
55+ case 0:
56+ typ, got, err := r.ReadObject(ids[k], buf)
57+ if err != nil || typ != TypeBlob || !bytes.Equal(got, blobs[k]) {
58+ fail("ReadObject: %v", err)
59+ return
60+ }
61+ buf = got
62+ case 1:
63+ size, rc, err := r.OpenBlob(ids[k])
64+ if err != nil {
65+ fail("OpenBlob: %v", err)
66+ return
67+ }
68+ // Read slowly so the repack happens meanwhile.
69+ got, err := io.ReadAll(io.LimitReader(rc, 1<<20))
70+ _ = rc.Close()
71+ if err != nil || size != int64(len(blobs[k])) || !bytes.Equal(got, blobs[k]) {
72+ fail("blob read: %v", err)
73+ return
74+ }
75+ case 2:
76+ if _, n, err := r.ObjectHeader(ids[k]); err != nil || n != int64(len(blobs[k])) {
77+ fail("ObjectHeader: %v", err)
78+ return
79+ }
80+ case 3:
81+ if _, err := r.ResolvePrefix(ids[k].String()[:12]); err != nil && !errors.Is(err, ErrAmbiguous) {
82+ fail("ResolvePrefix: %v", err)
83+ return
84+ }
85+ for ref, err := range r.Refs("refs/") {
86+ if err != nil {
87+ fail("Refs: %v", err)
88+ return
89+ }
90+ if ref.Name == "refs/heads/main" && ref.ID.IsZero() {
91+ fail("main is zero")
92+ return
93+ }
94+ }
95+ }
96+ reads.Add(1)
97+ }
98+ }(w)
99+ }
100+ // Refresher.
101+ wg.Add(1)
102+ go func() {
103+ defer wg.Done()
104+ for {
105+ select {
106+ case <-stop:
107+ return
108+ default:
109+ }
110+ if err := r.Refresh(); err != nil {
111+ fail("Refresh: %v", err)
112+ return
113+ }
114+ time.Sleep(time.Millisecond)
115+ }
116+ }()
117+
118+ // The writer: repack -ad (deletes the packs), new commits, pack-refs, prune.
119+ for round := 0; round < 12 && failures.Load() == 0; round++ {
120+ f.write("w.txt", []byte(fmt.Sprint(round)), 0o644)
121+ f.commit(fmt.Sprintf("w%d", round))
122+ f.git("tag", fmt.Sprintf("t%d", round))
123+ switch round % 3 {
124+ case 0:
125+ f.git("repack", "-ad")
126+ case 1:
127+ f.git("repack", "-adf", "--depth=20", "--window=20")
128+ f.git("pack-refs", "--all")
129+ case 2:
130+ f.git("gc", "--prune=now", "-q")
131+ }
132+ }
133+ close(stop)
134+ wg.Wait()
135+ if failures.Load() != 0 {
136+ t.FailNow()
137+ }
138+ if err := r.Refresh(); err != nil {
139+ t.Fatal(err)
140+ }
141+ t.Logf("%d reads during 12 repack rounds", reads.Load())
142+ compareRefs(t, r, f.root())
143+ compareObjects(t, r, f.root())
144+ // Packs that were replaced are unmapped once no reader needs them.
145+ if n := len(r.snap.Load().packs); n != 1 {
146+ t.Errorf("%d packs left in the snapshot", n)
147+ }
148+}
149+
150+// A reader blocked on an old pack keeps it mapped until it is closed.
151+func TestOldPackStaysMappedWhileInUse(t *testing.T) {
152+ f := newFx(t)
153+ big := textLines(100000, 1)
154+ f.write("big", big, 0o644)
155+ f.commit("one")
156+ f.git("repack", "-adf")
157+ r := f.open()
158+ id := mustID(t, f.git("rev-parse", "HEAD:big"))
159+ _, rc, err := r.OpenBlob(id)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ old := r.snap.Load().packs[0]
164+ buf := make([]byte, 1000)
165+ if _, err := io.ReadFull(rc, buf); err != nil {
166+ t.Fatal(err)
167+ }
168+ f.write("more", []byte("more"), 0o644)
169+ f.commit("two")
170+ f.git("repack", "-adf") // deletes the pack that rc reads from
171+ if err := r.Refresh(); err != nil {
172+ t.Fatal(err)
173+ }
174+ if r.snap.Load().packs[0] == old {
175+ t.Fatal("the pack list did not change")
176+ }
177+ if old.data == nil || old.refs.Load() != 1 {
178+ t.Fatalf("old pack released while in use (refs=%d)", old.refs.Load())
179+ }
180+ rest, err := io.ReadAll(rc)
181+ if err != nil || !bytes.Equal(append(buf, rest...), big) {
182+ t.Fatalf("read after repack: %v", err)
183+ }
184+ _ = rc.Close()
185+ if old.data != nil {
186+ t.Fatal("old pack still mapped after the last reader closed")
187+ }
188+}
189+
190+// A ref-delta cycle must not loop forever.
191+func TestRefDeltaCycle(t *testing.T) {
192+ // Two ref-deltas that are each other's base.
193+ idA, idB := ObjectID{0x10}, ObjectID{0x20}
194+ body := append(varint(4), varint(4)...)
195+ body = append(body, 4, 'a', 'b', 'c', 'd')
196+ ea := entryBytes(objRefDelta, len(body), idB[:], body)
197+ eb := entryBytes(objRefDelta, len(body), idA[:], body)
198+ var offs []uint64
199+ pk, idx := buildPack([][]byte{ea, eb}, []ObjectID{idA, idB}, &offs)
200+ g := t.TempDir()
201+ writeFile(t, g+"/HEAD", "ref: refs/heads/main\n")
202+ writeFile(t, g+"/refs/.keep", "")
203+ name := fmt.Sprintf("pack-%x", pk[len(pk)-20:])
204+ writeFile(t, g+"/objects/pack/"+name+".pack", string(pk))
205+ writeFile(t, g+"/objects/pack/"+name+".idx", string(idx))
206+ r, err := Open(g)
207+ if err != nil {
208+ t.Fatal(err)
209+ }
210+ defer func() { _ = r.Close() }()
211+ if _, _, err := r.ReadObject(idA, nil); !errors.Is(err, ErrCorrupt) {
212+ t.Fatalf("ReadObject = %v, want ErrCorrupt", err)
213+ }
214+ if _, _, err := r.ObjectHeader(idA); !errors.Is(err, ErrCorrupt) {
215+ t.Fatalf("ObjectHeader = %v, want ErrCorrupt", err)
216+ }
217+}

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