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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||