test(git): compare walks and LastCommits with git on a real repository
- commit
- e685703865af59d1155d43544f3f075cea14849c
- author
- brice · · 2026-10-10 21:19 UTC
- parent
- c6f4726
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +198 -0
M bench_test.go +1 -0
| @@ -484,6 +484,7 @@ func BenchmarkReadObjectSequence(b *testing.B) { | |||
| 484 | 484 | ||
| 485 | 485 | func TestMain(m *testing.M) { | |
| 486 | 486 | code := m.Run() | |
| 487 | + | cleanupRealWalk() | |
| 487 | 488 | if benchFx != nil && benchFx.r != nil { | |
| 488 | 489 | _ = benchFx.r.Close() | |
| 489 | 490 | if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" { | |
| @@ -484,6 +484,7 @@ func BenchmarkReadObjectSequence(b *testing.B) { | |||||
| 484 | 484 | ||||
| 485 | func TestMain(m *testing.M) { | 485 | func TestMain(m *testing.M) { | ||
| 486 | code := m.Run() | 486 | code := m.Run() | ||
| 487 | + | cleanupRealWalk() | |||
| 487 | if benchFx != nil && benchFx.r != nil { | 488 | if benchFx != nil && benchFx.r != nil { | ||
| 488 | _ = benchFx.r.Close() | 489 | _ = benchFx.r.Close() | ||
| 489 | if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" { | 490 | if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" { | ||
A walkreal_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package git | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "fmt" | |
| 9 | + | "math/rand" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path" | |
| 13 | + | "path/filepath" | |
| 14 | + | "slices" | |
| 15 | + | "sort" | |
| 16 | + | "strings" | |
| 17 | + | "sync" | |
| 18 | + | "testing" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // realWalkRepo clones the repository named by ERMITE_GIT_BENCH_REPO (bare, | |
| 22 | + | // no hardlinks, so the source is never touched) and writes a split | |
| 23 | + | // commit-graph with changed-path Bloom filters. The clone is shared by the | |
| 24 | + | // tests and benchmarks of the run. | |
| 25 | + | type realWalk struct { | |
| 26 | + | dir string | |
| 27 | + | r *Repo | |
| 28 | + | head ObjectID | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | var ( | |
| 32 | + | realWalkOnce sync.Once | |
| 33 | + | realWalkFx *realWalk | |
| 34 | + | realWalkErr error | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | func getRealWalk(tb testing.TB) *realWalk { | |
| 38 | + | tb.Helper() | |
| 39 | + | src := os.Getenv("ERMITE_GIT_BENCH_REPO") | |
| 40 | + | if src == "" { | |
| 41 | + | tb.Skip("ERMITE_GIT_BENCH_REPO is not set") | |
| 42 | + | } | |
| 43 | + | requireGit(tb) | |
| 44 | + | realWalkOnce.Do(func() { | |
| 45 | + | dir, err := os.MkdirTemp("", "ermite-walk-*") | |
| 46 | + | if err != nil { | |
| 47 | + | realWalkErr = err | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | clone := filepath.Join(dir, "clone.git") | |
| 51 | + | for _, args := range [][]string{ | |
| 52 | + | {"clone", "--quiet", "--bare", "--no-local", src, clone}, | |
| 53 | + | {"-C", clone, "commit-graph", "write", "--reachable", "--changed-paths", "--split"}, | |
| 54 | + | } { | |
| 55 | + | cmd := exec.Command("git", args...) | |
| 56 | + | cmd.Env = gitEnv() | |
| 57 | + | if out, err := cmd.CombinedOutput(); err != nil { | |
| 58 | + | realWalkErr = fmt.Errorf("git %v: %v\n%s", args, err, out) | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | r, err := Open(clone) | |
| 63 | + | if err != nil { | |
| 64 | + | realWalkErr = err | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | head, err := exec.Command("git", "-C", clone, "rev-parse", "HEAD").Output() | |
| 68 | + | if err != nil { | |
| 69 | + | realWalkErr = err | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | id, err := ParseID(strings.TrimSpace(string(head))) | |
| 73 | + | if err != nil { | |
| 74 | + | realWalkErr = err | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | realWalkFx = &realWalk{dir: clone, r: r, head: id} | |
| 78 | + | }) | |
| 79 | + | if realWalkErr != nil { | |
| 80 | + | tb.Fatal(realWalkErr) | |
| 81 | + | } | |
| 82 | + | return realWalkFx | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (x *realWalk) git(tb testing.TB, args ...string) string { | |
| 86 | + | tb.Helper() | |
| 87 | + | cmd := exec.Command("git", append([]string{"-C", x.dir}, args...)...) | |
| 88 | + | cmd.Env = gitEnv() | |
| 89 | + | var errb bytes.Buffer | |
| 90 | + | cmd.Stderr = &errb | |
| 91 | + | out, err := cmd.Output() | |
| 92 | + | if err != nil { | |
| 93 | + | tb.Fatalf("git %v: %v\n%s", args, err, errb.String()) | |
| 94 | + | } | |
| 95 | + | return string(out) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // cleanupRealWalk is called by TestMain. | |
| 99 | + | func cleanupRealWalk() { | |
| 100 | + | if realWalkFx != nil { | |
| 101 | + | _ = realWalkFx.r.Close() | |
| 102 | + | _ = os.RemoveAll(filepath.Dir(realWalkFx.dir)) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestWalkRealRepository(t *testing.T) { | |
| 107 | + | x := getRealWalk(t) | |
| 108 | + | head := x.head.String() | |
| 109 | + | ||
| 110 | + | // Full history. | |
| 111 | + | want := lines(x.git(t, "log", "--format=%H", head)) | |
| 112 | + | got := walkIDs(t, x.r, x.head, WalkOptions{}) | |
| 113 | + | if !slices.Equal(got, want) { | |
| 114 | + | t.Fatalf("full history differs: %d commits, git has %d", len(got), len(want)) | |
| 115 | + | } | |
| 116 | + | t.Logf("full history: %d commits", len(got)) | |
| 117 | + | for _, win := range [][2]int{{5000, 100}, {1, 1}, {len(want) - 3, 10}} { | |
| 118 | + | exp := window(want, win[0], win[1]) | |
| 119 | + | if g := walkIDs(t, x.r, x.head, WalkOptions{Skip: win[0], Limit: win[1]}); !slices.Equal(g, exp) { | |
| 120 | + | t.Errorf("skip %d limit %d differs", win[0], win[1]) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // 20 sampled paths: files and directories, deterministic. | |
| 125 | + | files := lines(x.git(t, "ls-tree", "-r", "--name-only", head)) | |
| 126 | + | dirSet := map[string]bool{} | |
| 127 | + | for _, f := range files { | |
| 128 | + | for d := path.Dir(f); d != "."; d = path.Dir(d) { | |
| 129 | + | dirSet[d] = true | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | dirs := make([]string, 0, len(dirSet)) | |
| 133 | + | for d := range dirSet { | |
| 134 | + | dirs = append(dirs, d) | |
| 135 | + | } | |
| 136 | + | sort.Strings(dirs) | |
| 137 | + | rng := rand.New(rand.NewSource(42)) | |
| 138 | + | var paths []string | |
| 139 | + | for len(paths) < 14 { | |
| 140 | + | paths = append(paths, files[rng.Intn(len(files))]) | |
| 141 | + | } | |
| 142 | + | for len(paths) < 20 && len(dirs) > 0 { | |
| 143 | + | paths = append(paths, dirs[rng.Intn(len(dirs))]) | |
| 144 | + | } | |
| 145 | + | paths = append(paths, "go.mod", "pkg/gui/") | |
| 146 | + | for _, p := range paths { | |
| 147 | + | want := lines(x.git(t, "log", "--format=%H", head, "--", p)) | |
| 148 | + | got := walkIDs(t, x.r, x.head, WalkOptions{Path: p}) | |
| 149 | + | if !slices.Equal(got, want) { | |
| 150 | + | t.Errorf("path %q: %d commits, git has %d", p, len(got), len(want)) | |
| 151 | + | continue | |
| 152 | + | } | |
| 153 | + | if len(want) > 7 { | |
| 154 | + | if g := walkIDs(t, x.r, x.head, WalkOptions{Path: p, Skip: 3, Limit: 4}); !slices.Equal(g, want[3:7]) { | |
| 155 | + | t.Errorf("path %q: skip/limit differ", p) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | t.Logf("%d paths compared", len(paths)) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestLastCommitsRealRepository(t *testing.T) { | |
| 163 | + | x := getRealWalk(t) | |
| 164 | + | head := x.head.String() | |
| 165 | + | // The root and three subdirectories, the biggest ones among the first. | |
| 166 | + | dirs := []string{""} | |
| 167 | + | for _, d := range []string{"pkg/gui", "pkg/commands", "docs"} { | |
| 168 | + | if x.git(t, "ls-tree", head, d+"/") != "" { | |
| 169 | + | dirs = append(dirs, d) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | for _, dir := range dirs { | |
| 173 | + | spec := head | |
| 174 | + | if dir != "" { | |
| 175 | + | spec += ":" + dir | |
| 176 | + | } | |
| 177 | + | var names []string | |
| 178 | + | for _, l := range lines(x.git(t, "ls-tree", "--name-only", spec)) { | |
| 179 | + | names = append(names, l) | |
| 180 | + | } | |
| 181 | + | if len(names) > 120 { | |
| 182 | + | names = names[:120] | |
| 183 | + | } | |
| 184 | + | got, err := x.r.LastCommits(context.Background(), x.head, dir, names) | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | for i, n := range names { | |
| 189 | + | p := path.Join(dir, n) | |
| 190 | + | exp := strings.TrimSpace(x.git(t, "log", "-n", "1", "--format=%H", head, "--", p)) | |
| 191 | + | if got[i].String() != exp { | |
| 192 | + | t.Errorf("%q: got %s want %s", p, got[i], exp) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | t.Logf("dir %q: %d entries compared", dir, len(names)) | |
| 196 | + | } | |
| 197 | + | } | |
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package git | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "fmt" | |||
| 9 | + | "math/rand" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "slices" | |||
| 15 | + | "sort" | |||
| 16 | + | "strings" | |||
| 17 | + | "sync" | |||
| 18 | + | "testing" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // realWalkRepo clones the repository named by ERMITE_GIT_BENCH_REPO (bare, | |||
| 22 | + | // no hardlinks, so the source is never touched) and writes a split | |||
| 23 | + | // commit-graph with changed-path Bloom filters. The clone is shared by the | |||
| 24 | + | // tests and benchmarks of the run. | |||
| 25 | + | type realWalk struct { | |||
| 26 | + | dir string | |||
| 27 | + | r *Repo | |||
| 28 | + | head ObjectID | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | var ( | |||
| 32 | + | realWalkOnce sync.Once | |||
| 33 | + | realWalkFx *realWalk | |||
| 34 | + | realWalkErr error | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | func getRealWalk(tb testing.TB) *realWalk { | |||
| 38 | + | tb.Helper() | |||
| 39 | + | src := os.Getenv("ERMITE_GIT_BENCH_REPO") | |||
| 40 | + | if src == "" { | |||
| 41 | + | tb.Skip("ERMITE_GIT_BENCH_REPO is not set") | |||
| 42 | + | } | |||
| 43 | + | requireGit(tb) | |||
| 44 | + | realWalkOnce.Do(func() { | |||
| 45 | + | dir, err := os.MkdirTemp("", "ermite-walk-*") | |||
| 46 | + | if err != nil { | |||
| 47 | + | realWalkErr = err | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | clone := filepath.Join(dir, "clone.git") | |||
| 51 | + | for _, args := range [][]string{ | |||
| 52 | + | {"clone", "--quiet", "--bare", "--no-local", src, clone}, | |||
| 53 | + | {"-C", clone, "commit-graph", "write", "--reachable", "--changed-paths", "--split"}, | |||
| 54 | + | } { | |||
| 55 | + | cmd := exec.Command("git", args...) | |||
| 56 | + | cmd.Env = gitEnv() | |||
| 57 | + | if out, err := cmd.CombinedOutput(); err != nil { | |||
| 58 | + | realWalkErr = fmt.Errorf("git %v: %v\n%s", args, err, out) | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | r, err := Open(clone) | |||
| 63 | + | if err != nil { | |||
| 64 | + | realWalkErr = err | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | head, err := exec.Command("git", "-C", clone, "rev-parse", "HEAD").Output() | |||
| 68 | + | if err != nil { | |||
| 69 | + | realWalkErr = err | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | id, err := ParseID(strings.TrimSpace(string(head))) | |||
| 73 | + | if err != nil { | |||
| 74 | + | realWalkErr = err | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | realWalkFx = &realWalk{dir: clone, r: r, head: id} | |||
| 78 | + | }) | |||
| 79 | + | if realWalkErr != nil { | |||
| 80 | + | tb.Fatal(realWalkErr) | |||
| 81 | + | } | |||
| 82 | + | return realWalkFx | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (x *realWalk) git(tb testing.TB, args ...string) string { | |||
| 86 | + | tb.Helper() | |||
| 87 | + | cmd := exec.Command("git", append([]string{"-C", x.dir}, args...)...) | |||
| 88 | + | cmd.Env = gitEnv() | |||
| 89 | + | var errb bytes.Buffer | |||
| 90 | + | cmd.Stderr = &errb | |||
| 91 | + | out, err := cmd.Output() | |||
| 92 | + | if err != nil { | |||
| 93 | + | tb.Fatalf("git %v: %v\n%s", args, err, errb.String()) | |||
| 94 | + | } | |||
| 95 | + | return string(out) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // cleanupRealWalk is called by TestMain. | |||
| 99 | + | func cleanupRealWalk() { | |||
| 100 | + | if realWalkFx != nil { | |||
| 101 | + | _ = realWalkFx.r.Close() | |||
| 102 | + | _ = os.RemoveAll(filepath.Dir(realWalkFx.dir)) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestWalkRealRepository(t *testing.T) { | |||
| 107 | + | x := getRealWalk(t) | |||
| 108 | + | head := x.head.String() | |||
| 109 | + | ||||
| 110 | + | // Full history. | |||
| 111 | + | want := lines(x.git(t, "log", "--format=%H", head)) | |||
| 112 | + | got := walkIDs(t, x.r, x.head, WalkOptions{}) | |||
| 113 | + | if !slices.Equal(got, want) { | |||
| 114 | + | t.Fatalf("full history differs: %d commits, git has %d", len(got), len(want)) | |||
| 115 | + | } | |||
| 116 | + | t.Logf("full history: %d commits", len(got)) | |||
| 117 | + | for _, win := range [][2]int{{5000, 100}, {1, 1}, {len(want) - 3, 10}} { | |||
| 118 | + | exp := window(want, win[0], win[1]) | |||
| 119 | + | if g := walkIDs(t, x.r, x.head, WalkOptions{Skip: win[0], Limit: win[1]}); !slices.Equal(g, exp) { | |||
| 120 | + | t.Errorf("skip %d limit %d differs", win[0], win[1]) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // 20 sampled paths: files and directories, deterministic. | |||
| 125 | + | files := lines(x.git(t, "ls-tree", "-r", "--name-only", head)) | |||
| 126 | + | dirSet := map[string]bool{} | |||
| 127 | + | for _, f := range files { | |||
| 128 | + | for d := path.Dir(f); d != "."; d = path.Dir(d) { | |||
| 129 | + | dirSet[d] = true | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | dirs := make([]string, 0, len(dirSet)) | |||
| 133 | + | for d := range dirSet { | |||
| 134 | + | dirs = append(dirs, d) | |||
| 135 | + | } | |||
| 136 | + | sort.Strings(dirs) | |||
| 137 | + | rng := rand.New(rand.NewSource(42)) | |||
| 138 | + | var paths []string | |||
| 139 | + | for len(paths) < 14 { | |||
| 140 | + | paths = append(paths, files[rng.Intn(len(files))]) | |||
| 141 | + | } | |||
| 142 | + | for len(paths) < 20 && len(dirs) > 0 { | |||
| 143 | + | paths = append(paths, dirs[rng.Intn(len(dirs))]) | |||
| 144 | + | } | |||
| 145 | + | paths = append(paths, "go.mod", "pkg/gui/") | |||
| 146 | + | for _, p := range paths { | |||
| 147 | + | want := lines(x.git(t, "log", "--format=%H", head, "--", p)) | |||
| 148 | + | got := walkIDs(t, x.r, x.head, WalkOptions{Path: p}) | |||
| 149 | + | if !slices.Equal(got, want) { | |||
| 150 | + | t.Errorf("path %q: %d commits, git has %d", p, len(got), len(want)) | |||
| 151 | + | continue | |||
| 152 | + | } | |||
| 153 | + | if len(want) > 7 { | |||
| 154 | + | if g := walkIDs(t, x.r, x.head, WalkOptions{Path: p, Skip: 3, Limit: 4}); !slices.Equal(g, want[3:7]) { | |||
| 155 | + | t.Errorf("path %q: skip/limit differ", p) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | t.Logf("%d paths compared", len(paths)) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestLastCommitsRealRepository(t *testing.T) { | |||
| 163 | + | x := getRealWalk(t) | |||
| 164 | + | head := x.head.String() | |||
| 165 | + | // The root and three subdirectories, the biggest ones among the first. | |||
| 166 | + | dirs := []string{""} | |||
| 167 | + | for _, d := range []string{"pkg/gui", "pkg/commands", "docs"} { | |||
| 168 | + | if x.git(t, "ls-tree", head, d+"/") != "" { | |||
| 169 | + | dirs = append(dirs, d) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | for _, dir := range dirs { | |||
| 173 | + | spec := head | |||
| 174 | + | if dir != "" { | |||
| 175 | + | spec += ":" + dir | |||
| 176 | + | } | |||
| 177 | + | var names []string | |||
| 178 | + | for _, l := range lines(x.git(t, "ls-tree", "--name-only", spec)) { | |||
| 179 | + | names = append(names, l) | |||
| 180 | + | } | |||
| 181 | + | if len(names) > 120 { | |||
| 182 | + | names = names[:120] | |||
| 183 | + | } | |||
| 184 | + | got, err := x.r.LastCommits(context.Background(), x.head, dir, names) | |||
| 185 | + | if err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | for i, n := range names { | |||
| 189 | + | p := path.Join(dir, n) | |||
| 190 | + | exp := strings.TrimSpace(x.git(t, "log", "-n", "1", "--format=%H", head, "--", p)) | |||
| 191 | + | if got[i].String() != exp { | |||
| 192 | + | t.Errorf("%q: got %s want %s", p, got[i], exp) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | t.Logf("dir %q: %d entries compared", dir, len(names)) | |||
| 196 | + | } | |||
| 197 | + | } | |||