feat(ermitegit): add the RepoReader adapter over ermite-git
The adapter wraps gitcli and routes each method by a code constant (routes.go, with the benchmark numbers that decided it): ListRefs, ResolveRevision by name or full id, and ListCommits of a revision go to ermite-git; the rest, and GetCommit, stay on the CLI. A repository that Open rejects with ErrUnsupported is served by gitcli for every method, logged once at info. A bounded LRU keeps repositories open, keyed by directory and inode, closed when the last read is done. Reads see a push at once (the library reads refs from disk and rescans the packs on a missing object); Refresh runs at most once a second per repository. TestFreshness proves it with Refresh off. The RepoReader tests of gitcli move to internal/readertest as a table that runs against both adapters, with fixtures (merges, tags, deep paths, large blobs, unborn and detached HEAD, commit-graph v1, v2, none and stale, submodule, wide directories, odd names and messages) and a comparison of the two on them and on ERMITE_GIT_BENCH_REPO. BenchmarkRepoReader compares the methods.
- commit
- b54f0d3320495df8d7ff15f33a2c5adeeb77f054
- author
- brice · · 2026-10-10 23:07 UTC
- parent
- 65a5c34
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +3304 -566
M go.mod +1 -0
| @@ -13,6 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed | 15 | bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed | ||
| 16 | + | bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0 | |||
| 16 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 17 | connectrpc.com/connect v1.21.0 | 18 | connectrpc.com/connect v1.21.0 | ||
| 18 | github.com/a-h/templ v0.3.1020 | 19 | github.com/a-h/templ v0.3.1020 | ||
M go.sum +2 -0 generated file: collapsed
A internal/adapters/ermitegit/bench_test.go +255 -0
| @@ -0,0 +1,255 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "io/fs" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 20 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 21 | + | "bnema.dev/ermite/internal/domain" | |||
| 22 | + | "bnema.dev/ermite/internal/ports" | |||
| 23 | + | "bnema.dev/ermite/internal/readertest" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // benchTarget is what the benchmarks ask about: a repository and the names in | |||
| 27 | + | // it, found with git. | |||
| 28 | + | type benchTarget struct { | |||
| 29 | + | name domain.RepoName | |||
| 30 | + | dir string | |||
| 31 | + | branch string // a branch name | |||
| 32 | + | head string // the full id of HEAD | |||
| 33 | + | abbrev string // an abbreviated commit id | |||
| 34 | + | refPath string // "branch/dir/file" | |||
| 35 | + | treeDir string // a directory with many entries | |||
| 36 | + | file string // a text file of some KiB | |||
| 37 | + | big string // the largest blob of HEAD | |||
| 38 | + | history string // a path with a long history | |||
| 39 | + | deepSkip int // a page far down the history | |||
| 40 | + | commitsAll int | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // newBenchTarget builds the fixture repository, or clones EnvBenchRepo when it | |||
| 44 | + | // is set, and looks for the targets with git. | |||
| 45 | + | func newBenchTarget(b *testing.B, g *gitcli.Git, data string) benchTarget { | |||
| 46 | + | b.Helper() | |||
| 47 | + | var t benchTarget | |||
| 48 | + | if os.Getenv(EnvBenchRepo) != "" { | |||
| 49 | + | r := cloneBench(b, g, data) | |||
| 50 | + | t.name, t.dir = r.name, r.dir | |||
| 51 | + | } else { | |||
| 52 | + | f := benchFixture(b, data) | |||
| 53 | + | t.name, t.dir = f.Name, f.Bare | |||
| 54 | + | f.Maintain() | |||
| 55 | + | } | |||
| 56 | + | ageFiles(b, t.dir) | |||
| 57 | + | git := func(args ...string) string { return gitOutB(b, t.dir, args...) } | |||
| 58 | + | t.branch = git("symbolic-ref", "--short", "HEAD") | |||
| 59 | + | head := git("rev-parse", "HEAD") | |||
| 60 | + | t.head, t.abbrev = head, head[:10] | |||
| 61 | + | t.commitsAll, _ = strconv.Atoi(git("rev-list", "--count", "HEAD")) | |||
| 62 | + | t.deepSkip = min(5000, t.commitsAll*2/3) | |||
| 63 | + | ||||
| 64 | + | var bigSize int64 | |||
| 65 | + | dirCount := map[string]int{} | |||
| 66 | + | var files []string | |||
| 67 | + | for _, l := range strings.Split(git("ls-tree", "-r", "-l", "HEAD"), "\n") { | |||
| 68 | + | meta, p, ok := strings.Cut(l, "\t") | |||
| 69 | + | f := strings.Fields(meta) | |||
| 70 | + | if !ok || len(f) != 4 || f[1] != "blob" { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | size, _ := strconv.ParseInt(f[3], 10, 64) | |||
| 74 | + | if size > bigSize { | |||
| 75 | + | bigSize, t.big = size, p | |||
| 76 | + | } | |||
| 77 | + | if i := strings.LastIndexByte(p, '/'); i >= 0 { | |||
| 78 | + | dirCount[p[:i]]++ | |||
| 79 | + | } | |||
| 80 | + | if size >= 2<<10 && size <= 100<<10 && strings.Count(p, "/") <= 2 && !strings.ContainsAny(p, " *") { | |||
| 81 | + | files = append(files, p) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | for d, n := range dirCount { | |||
| 85 | + | if n > dirCount[t.treeDir] || (n == dirCount[t.treeDir] && d < t.treeDir) { | |||
| 86 | + | t.treeDir = d | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | require.NotEmpty(b, files, "no text file to read") | |||
| 90 | + | t.file = files[len(files)/2] | |||
| 91 | + | t.history = t.file | |||
| 92 | + | t.refPath = t.branch + "/" + t.file | |||
| 93 | + | return t | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // BenchmarkRepoReader compares the CLI reader and the ermite-git adapter, | |||
| 97 | + | // method by method, on a generated repository or, when ERMITE_GIT_BENCH_REPO | |||
| 98 | + | // is set, on a clone of that repository: | |||
| 99 | + | // | |||
| 100 | + | // go test -run '^$' -bench RepoReader -benchmem ./internal/adapters/ermitegit | |||
| 101 | + | // ERMITE_GIT_BENCH_REPO=/path/to/repo/.git go test -run '^$' -bench RepoReader -benchmem ... | |||
| 102 | + | func BenchmarkRepoReader(b *testing.B) { | |||
| 103 | + | a, g, data := newAdapter(b, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 104 | + | t := newBenchTarget(b, g, data) | |||
| 105 | + | b.Logf("repository: %d commits, branch %s, dir %s, file %s, big %s, deep skip %d", t.commitsAll, t.branch, t.treeDir, t.file, t.big, t.deepSkip) | |||
| 106 | + | ctx := context.Background() | |||
| 107 | + | rev := func(s string) domain.Revision { | |||
| 108 | + | r, err := domain.ParseRevision(s) | |||
| 109 | + | require.NoError(b, err) | |||
| 110 | + | return r | |||
| 111 | + | } | |||
| 112 | + | path := func(s string) domain.TreePath { | |||
| 113 | + | p, err := domain.ParseTreePath(s) | |||
| 114 | + | require.NoError(b, err) | |||
| 115 | + | return p | |||
| 116 | + | } | |||
| 117 | + | drain := func(r ports.RepoReader, v domain.Revision, p domain.TreePath) error { | |||
| 118 | + | _, rc, err := r.OpenBlob(ctx, t.name, v, p) | |||
| 119 | + | if err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | _, err = io.Copy(io.Discard, rc) | |||
| 123 | + | _ = rc.Close() | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | // ermite-git re-reads a commit-graph it loaded less than two seconds ago on | |||
| 127 | + | // every Refresh (see ageFiles). Open the repository and let that window | |||
| 128 | + | // pass, as a long-lived server would have. | |||
| 129 | + | require.NoError(b, drain(a, domain.Revision{}, path(t.file))) | |||
| 130 | + | time.Sleep(2100 * time.Millisecond) | |||
| 131 | + | type method struct { | |||
| 132 | + | name string | |||
| 133 | + | run func(r ports.RepoReader) error | |||
| 134 | + | } | |||
| 135 | + | methods := []method{ | |||
| 136 | + | {"ListRefs", func(r ports.RepoReader) (err error) { _, err = r.ListRefs(ctx, t.name); return }}, | |||
| 137 | + | {"ResolveRevision/branch", func(r ports.RepoReader) (err error) { | |||
| 138 | + | _, err = r.ResolveRevision(ctx, t.name, rev(t.branch)) | |||
| 139 | + | return | |||
| 140 | + | }}, | |||
| 141 | + | {"ResolveRevision/fullid", func(r ports.RepoReader) (err error) { | |||
| 142 | + | _, err = r.ResolveRevision(ctx, t.name, rev(t.head)) | |||
| 143 | + | return | |||
| 144 | + | }}, | |||
| 145 | + | {"ResolveRevision/abbrev", func(r ports.RepoReader) (err error) { | |||
| 146 | + | _, err = r.ResolveRevision(ctx, t.name, rev(t.abbrev)) | |||
| 147 | + | return | |||
| 148 | + | }}, | |||
| 149 | + | {"ResolveRefPath", func(r ports.RepoReader) (err error) { _, err = r.ResolveRefPath(ctx, t.name, t.refPath); return }}, | |||
| 150 | + | {"GetTree/root", func(r ports.RepoReader) (err error) { | |||
| 151 | + | _, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, false) | |||
| 152 | + | return | |||
| 153 | + | }}, | |||
| 154 | + | {"GetTree/dir", func(r ports.RepoReader) (err error) { | |||
| 155 | + | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), false) | |||
| 156 | + | return | |||
| 157 | + | }}, | |||
| 158 | + | {"GetTree/root+last", func(r ports.RepoReader) (err error) { | |||
| 159 | + | _, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, true) | |||
| 160 | + | return | |||
| 161 | + | }}, | |||
| 162 | + | {"GetTree/dir+last", func(r ports.RepoReader) (err error) { | |||
| 163 | + | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | |||
| 164 | + | return | |||
| 165 | + | }}, | |||
| 166 | + | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | |||
| 167 | + | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | |||
| 168 | + | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | |||
| 169 | + | _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Limit: 30}) | |||
| 170 | + | return | |||
| 171 | + | }}, | |||
| 172 | + | {"ListCommits/deep", func(r ports.RepoReader) (err error) { | |||
| 173 | + | _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Skip: t.deepSkip, Limit: 30}) | |||
| 174 | + | return | |||
| 175 | + | }}, | |||
| 176 | + | {"ListCommits/path", func(r ports.RepoReader) (err error) { | |||
| 177 | + | _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.history), Limit: 30}) | |||
| 178 | + | return | |||
| 179 | + | }}, | |||
| 180 | + | {"ListCommits/dirpath", func(r ports.RepoReader) (err error) { | |||
| 181 | + | _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.treeDir), Limit: 30}) | |||
| 182 | + | return | |||
| 183 | + | }}, | |||
| 184 | + | } | |||
| 185 | + | for _, m := range methods { | |||
| 186 | + | b.Run(m.name, func(b *testing.B) { | |||
| 187 | + | for _, ad := range []struct { | |||
| 188 | + | name string | |||
| 189 | + | r ports.RepoReader | |||
| 190 | + | }{{"cli", g}, {"ermitegit", a}} { | |||
| 191 | + | b.Run(ad.name, func(b *testing.B) { | |||
| 192 | + | require.NoError(b, m.run(ad.r)) // warm-up: processes, caches, graph | |||
| 193 | + | b.ReportAllocs() | |||
| 194 | + | b.ResetTimer() | |||
| 195 | + | for b.Loop() { | |||
| 196 | + | if err := m.run(ad.r); err != nil { | |||
| 197 | + | b.Fatal(err) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | }) | |||
| 201 | + | } | |||
| 202 | + | }) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // benchFixture builds the generated repository: a few hundred commits over a | |||
| 207 | + | // tree of directories, merges, tags, and a 10 MiB blob. | |||
| 208 | + | func benchFixture(b *testing.B, data string) *readertest.Fixture { | |||
| 209 | + | b.Helper() | |||
| 210 | + | f := readertest.NewFixture(b, data, "bench") | |||
| 211 | + | f.Git("config", "commitGraph.changedPathsVersion", "2") | |||
| 212 | + | f.Git("config", "core.compression", "1") | |||
| 213 | + | for i := range 200 { | |||
| 214 | + | for j := range 3 { | |||
| 215 | + | f.Write(fmt.Sprintf("pkg%d/file%02d.go", (i+j)%8, (i*3+j)%40), strings.Repeat(fmt.Sprintf("// version %d of this file\n", i), 160)) | |||
| 216 | + | } | |||
| 217 | + | f.Write("README.md", fmt.Sprintf("# project\n\nrevision %d\n", i)) | |||
| 218 | + | f.Commit(fmt.Sprintf("change %d\n\nbody of the message", i)) | |||
| 219 | + | if i%25 == 24 { | |||
| 220 | + | f.CheckoutNew(fmt.Sprintf("topic%d", i)) | |||
| 221 | + | f.Write(fmt.Sprintf("topics/topic%d.txt", i), "t\n") | |||
| 222 | + | f.Commit("topic work") | |||
| 223 | + | f.Checkout("main") | |||
| 224 | + | f.Write("pkg0/main-work.go", fmt.Sprintf("// main work %d\n", i)) | |||
| 225 | + | f.Commit("main work") | |||
| 226 | + | f.Merge(fmt.Sprintf("topic%d", i)) | |||
| 227 | + | f.Git("tag", "-a", "-m", "release", fmt.Sprintf("v%d", i/25)) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | f.WriteBytes("assets/big.bin", readertest.RandomBytes(10<<20, 5)) | |||
| 231 | + | f.Commit("add a 10 MiB asset") | |||
| 232 | + | f.Git("gc", "--quiet") | |||
| 233 | + | return f | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // ageFiles sets the modification time of every file of the repository to an | |||
| 237 | + | // hour ago. ermite-git re-reads the files modified in the last two seconds (the | |||
| 238 | + | // racy window of git's index rule): a repository that was just built would | |||
| 239 | + | // measure that path. A repository that serves reads is not in that state, its | |||
| 240 | + | // last push is older. | |||
| 241 | + | func ageFiles(b *testing.B, dir string) { | |||
| 242 | + | b.Helper() | |||
| 243 | + | old := time.Now().Add(-time.Hour) | |||
| 244 | + | require.NoError(b, filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { | |||
| 245 | + | if err != nil { | |||
| 246 | + | return err | |||
| 247 | + | } | |||
| 248 | + | return os.Chtimes(p, old, old) | |||
| 249 | + | })) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func gitOutB(b *testing.B, dir string, args ...string) string { | |||
| 253 | + | b.Helper() | |||
| 254 | + | return readertest.GitOut(b, dir, args...) | |||
| 255 | + | } | |||
A internal/adapters/ermitegit/bigrepo_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // EnvBenchRepo names a real repository (its .git directory) for the | |||
| 19 | + | // comparison test and the benchmarks, which skip without it. It is never | |||
| 20 | + | // modified: it is cloned. | |||
| 21 | + | const EnvBenchRepo = "ERMITE_GIT_BENCH_REPO" | |||
| 22 | + | ||||
| 23 | + | // bigRepo is a bare clone of the repository of EnvBenchRepo, configured like | |||
| 24 | + | // a repository ermite creates and maintained the way ermite does. | |||
| 25 | + | type bigRepo struct { | |||
| 26 | + | name domain.RepoName | |||
| 27 | + | dir string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // cloneBench clones the repository of EnvBenchRepo into the data directory with | |||
| 31 | + | // --no-local (a real pack transfer, so that the clone is packed as a push | |||
| 32 | + | // would leave it), applies the settings of gitcli.Stage and runs | |||
| 33 | + | // gitcli.Maintain. It skips the test when the variable is unset. | |||
| 34 | + | func cloneBench(tb testing.TB, g *gitcli.Git, data string) bigRepo { | |||
| 35 | + | tb.Helper() | |||
| 36 | + | src := os.Getenv(EnvBenchRepo) | |||
| 37 | + | if src == "" { | |||
| 38 | + | tb.Skipf("%s is not set", EnvBenchRepo) | |||
| 39 | + | } | |||
| 40 | + | name, err := domain.ParseRepoName("bench") | |||
| 41 | + | require.NoError(tb, err) | |||
| 42 | + | dir := gitcli.RepoDir(data, name) | |||
| 43 | + | run := func(args ...string) { | |||
| 44 | + | tb.Helper() | |||
| 45 | + | cmd := exec.Command("git", args...) | |||
| 46 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(data, "run", "git-home"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | |||
| 47 | + | out, err := cmd.CombinedOutput() | |||
| 48 | + | require.NoError(tb, err, "git %v: %s", args, out) | |||
| 49 | + | } | |||
| 50 | + | run("clone", "--quiet", "--bare", "--no-local", src, dir) | |||
| 51 | + | for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} { | |||
| 52 | + | run("-C", dir, "config", "--local", kv[0], kv[1]) | |||
| 53 | + | } | |||
| 54 | + | run("-C", dir, "gc", "--quiet", "--prune=now") | |||
| 55 | + | require.NoError(tb, g.Maintain(context.Background(), name)) | |||
| 56 | + | return bigRepo{name: name, dir: dir} | |||
| 57 | + | } | |||
A internal/adapters/ermitegit/cache.go +305 -0
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "container/list" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io/fs" | |||
| 11 | + | "os" | |||
| 12 | + | "sync" | |||
| 13 | + | "sync/atomic" | |||
| 14 | + | "syscall" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | git "bnema.dev/ermite-git" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // DefaultMaxRepos is the number of repositories kept open when Config.MaxRepos | |||
| 23 | + | // is zero. | |||
| 24 | + | const DefaultMaxRepos = 64 | |||
| 25 | + | ||||
| 26 | + | // DefaultRefreshEvery is how long a repository goes without a Refresh when | |||
| 27 | + | // Config.RefreshEvery is zero. See Config.RefreshEvery for what depends on it. | |||
| 28 | + | const DefaultRefreshEvery = time.Second | |||
| 29 | + | ||||
| 30 | + | // unsupportedRetry is how long a repository that ermite-git cannot read is | |||
| 31 | + | // served by git before Open is tried again (a repack, or the removal of a | |||
| 32 | + | // replace ref, can make it readable). | |||
| 33 | + | const unsupportedRetry = time.Minute | |||
| 34 | + | ||||
| 35 | + | // entry is the state of one repository directory in the cache: an open | |||
| 36 | + | // *git.Repo, or the reason it is not readable by ermite-git. | |||
| 37 | + | // | |||
| 38 | + | // The fields below the line belong to the cache and are guarded by its mutex. | |||
| 39 | + | type entry struct { | |||
| 40 | + | dir string | |||
| 41 | + | ino uint64 // of the directory: a new repository under the same name differs | |||
| 42 | + | dev uint64 | |||
| 43 | + | once sync.Once | |||
| 44 | + | // opened is the result of git.Open, published by the Once; nil before. | |||
| 45 | + | opened atomic.Pointer[opened] | |||
| 46 | + | ||||
| 47 | + | refreshed atomic.Int64 // unix nanoseconds of the last Refresh | |||
| 48 | + | ||||
| 49 | + | elem *list.Element | |||
| 50 | + | users int | |||
| 51 | + | dropped bool // out of the cache: close when the last user is done | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // opened is what git.Open answered for an entry. | |||
| 55 | + | type opened struct { | |||
| 56 | + | repo *git.Repo // nil when unsupported | |||
| 57 | + | err error // Open failed with something else than ErrUnsupported | |||
| 58 | + | at time.Time | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // handle is a use of an entry; release it when the read is over. repo is nil | |||
| 62 | + | // when git must serve the read. | |||
| 63 | + | type handle struct { | |||
| 64 | + | c *cache | |||
| 65 | + | e *entry | |||
| 66 | + | repo *git.Repo | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (h *handle) release() { h.c.release(h.e) } | |||
| 70 | + | ||||
| 71 | + | // cache is a bounded LRU of open repositories keyed by directory. A repository | |||
| 72 | + | // leaves it by eviction, by Forget (the directory was deleted) or because the | |||
| 73 | + | // directory is no longer the same one (checked on each use through its | |||
| 74 | + | // inode); it is closed once the reads that use it are done. | |||
| 75 | + | type cache struct { | |||
| 76 | + | max int | |||
| 77 | + | refreshEvery time.Duration | |||
| 78 | + | onUnsupport func(ctx context.Context, dir string, name domain.RepoName, cause error) | |||
| 79 | + | ||||
| 80 | + | mu sync.Mutex | |||
| 81 | + | entries map[string]*entry | |||
| 82 | + | lru *list.List // front = most recently used | |||
| 83 | + | closed bool | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func newCache(max int, refreshEvery time.Duration, onUnsupport func(context.Context, string, domain.RepoName, error)) *cache { | |||
| 87 | + | if max <= 0 { | |||
| 88 | + | max = DefaultMaxRepos | |||
| 89 | + | } | |||
| 90 | + | return &cache{max: max, refreshEvery: refreshEvery, onUnsupport: onUnsupport, entries: make(map[string]*entry), lru: list.New()} | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // get returns the entry of dir, opened and refreshed, or a nil repo when | |||
| 94 | + | // ermite-git does not read the repository. A missing directory is | |||
| 95 | + | // domain.ErrNotFound. | |||
| 96 | + | func (c *cache) get(ctx context.Context, name domain.RepoName, dir string) (*handle, error) { | |||
| 97 | + | fi, err := os.Stat(dir) | |||
| 98 | + | if err != nil { | |||
| 99 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 100 | + | return nil, domain.ErrNotFound | |||
| 101 | + | } | |||
| 102 | + | return nil, fmt.Errorf("repository %q: %w", name, err) | |||
| 103 | + | } | |||
| 104 | + | if !fi.IsDir() { | |||
| 105 | + | return nil, domain.ErrNotFound | |||
| 106 | + | } | |||
| 107 | + | dev, ino := identity(fi) | |||
| 108 | + | ||||
| 109 | + | c.mu.Lock() | |||
| 110 | + | if c.closed { | |||
| 111 | + | c.mu.Unlock() | |||
| 112 | + | return nil, ErrClosed | |||
| 113 | + | } | |||
| 114 | + | e := c.entries[dir] | |||
| 115 | + | if e != nil && (e.ino != ino || e.dev != dev || e.stale()) { | |||
| 116 | + | c.dropLocked(e) | |||
| 117 | + | e = nil | |||
| 118 | + | } | |||
| 119 | + | if e == nil { | |||
| 120 | + | e = &entry{dir: dir, ino: ino, dev: dev} | |||
| 121 | + | e.elem = c.lru.PushFront(e) | |||
| 122 | + | c.entries[dir] = e | |||
| 123 | + | c.evictLocked() | |||
| 124 | + | } else { | |||
| 125 | + | c.lru.MoveToFront(e.elem) | |||
| 126 | + | } | |||
| 127 | + | e.users++ | |||
| 128 | + | c.mu.Unlock() | |||
| 129 | + | ||||
| 130 | + | h := &handle{c: c, e: e} | |||
| 131 | + | e.once.Do(func() { c.open(ctx, name, e) }) | |||
| 132 | + | op := e.opened.Load() | |||
| 133 | + | if op.err != nil { | |||
| 134 | + | c.forgetEntry(e) // the next read tries again | |||
| 135 | + | h.release() | |||
| 136 | + | return nil, fmt.Errorf("open repository %q: %w", name, mapOpenErr(op.err)) | |||
| 137 | + | } | |||
| 138 | + | if op.repo == nil { | |||
| 139 | + | return h, nil // unsupported | |||
| 140 | + | } | |||
| 141 | + | if err := c.refresh(e, op.repo); err != nil { | |||
| 142 | + | if errors.Is(err, git.ErrUnsupported) { | |||
| 143 | + | h.fallBack(ctx, name, err) | |||
| 144 | + | return h, nil | |||
| 145 | + | } | |||
| 146 | + | h.release() | |||
| 147 | + | return nil, fmt.Errorf("refresh repository %q: %w", name, err) | |||
| 148 | + | } | |||
| 149 | + | h.repo = op.repo | |||
| 150 | + | return h, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // refresh calls Refresh on the repository of e unless one ran less than | |||
| 154 | + | // refreshEvery ago (a negative interval refreshes on every read). The library | |||
| 155 | + | // reads loose refs live, stat-checks packed-refs on each ref read and rescans | |||
| 156 | + | // the packs when an object is missing, so a read sees a push whether or not | |||
| 157 | + | // Refresh ran; Refresh is what drops replaced packs, picks up a new | |||
| 158 | + | // commit-graph and notices that the repository became unsupported. | |||
| 159 | + | func (c *cache) refresh(e *entry, repo *git.Repo) error { | |||
| 160 | + | now := time.Now().UnixNano() | |||
| 161 | + | if c.refreshEvery >= 0 && now-e.refreshed.Load() < int64(c.refreshEvery) { | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | if err := repo.Refresh(); err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | e.refreshed.Store(now) | |||
| 168 | + | return nil | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // stale reports whether a repository that ermite-git did not read is due for | |||
| 172 | + | // another try. The caller holds the cache mutex. | |||
| 173 | + | func (e *entry) stale() bool { | |||
| 174 | + | op := e.opened.Load() | |||
| 175 | + | return op != nil && op.repo == nil && op.err == nil && time.Since(op.at) > unsupportedRetry | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // open runs git.Open once per entry; concurrent users wait in Once.Do. | |||
| 179 | + | func (c *cache) open(ctx context.Context, name domain.RepoName, e *entry) { | |||
| 180 | + | op := &opened{at: time.Now()} | |||
| 181 | + | repo, err := git.Open(e.dir) | |||
| 182 | + | switch { | |||
| 183 | + | case err == nil: | |||
| 184 | + | op.repo = repo | |||
| 185 | + | case errors.Is(err, git.ErrUnsupported): | |||
| 186 | + | c.onUnsupport(ctx, e.dir, name, err) | |||
| 187 | + | default: | |||
| 188 | + | op.err = err | |||
| 189 | + | } | |||
| 190 | + | e.opened.Store(op) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // mapOpenErr maps the failure of git.Open: a directory that is not a | |||
| 194 | + | // repository is a missing repository. | |||
| 195 | + | func mapOpenErr(err error) error { | |||
| 196 | + | if errors.Is(err, git.ErrNotFound) { | |||
| 197 | + | return fmt.Errorf("%w: %w", domain.ErrNotFound, err) | |||
| 198 | + | } | |||
| 199 | + | return err | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // markUnsupported turns a repository that stopped being readable (a replace | |||
| 203 | + | // ref was pushed, say) into an unsupported one: git serves it from now on. | |||
| 204 | + | func (c *cache) markUnsupported(ctx context.Context, name domain.RepoName, e *entry, cause error) { | |||
| 205 | + | c.onUnsupport(ctx, e.dir, name, cause) | |||
| 206 | + | c.mu.Lock() | |||
| 207 | + | defer c.mu.Unlock() | |||
| 208 | + | if c.entries[e.dir] == e { | |||
| 209 | + | c.dropLocked(e) | |||
| 210 | + | n := &entry{dir: e.dir, ino: e.ino, dev: e.dev} | |||
| 211 | + | n.once.Do(func() { n.opened.Store(&opened{at: time.Now()}) }) // no repo, no error: unsupported | |||
| 212 | + | n.elem = c.lru.PushFront(n) | |||
| 213 | + | c.entries[e.dir] = n | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // fallBack records that the repository stopped being readable by ermite-git | |||
| 218 | + | // (a replace ref was pushed, a feature appeared midway): git serves it from | |||
| 219 | + | // now on, and this read too. | |||
| 220 | + | func (h *handle) fallBack(ctx context.Context, name domain.RepoName, cause error) { | |||
| 221 | + | h.repo = nil | |||
| 222 | + | h.c.markUnsupported(ctx, name, h.e, cause) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (c *cache) release(e *entry) { | |||
| 226 | + | c.mu.Lock() | |||
| 227 | + | e.users-- | |||
| 228 | + | closeNow := e.dropped && e.users == 0 | |||
| 229 | + | c.mu.Unlock() | |||
| 230 | + | if closeNow { | |||
| 231 | + | e.closeRepo() | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // closeRepo closes the repository of an entry that left the cache and has no | |||
| 236 | + | // user. An entry whose open has not finished has no repository yet: its last | |||
| 237 | + | // user closes it. | |||
| 238 | + | func (e *entry) closeRepo() { | |||
| 239 | + | if op := e.opened.Load(); op != nil && op.repo != nil { | |||
| 240 | + | _ = op.repo.Close() | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // dropLocked removes e from the cache. The caller holds the mutex. The | |||
| 245 | + | // repository is closed by the last user (or by the caller if it has none). | |||
| 246 | + | func (c *cache) dropLocked(e *entry) { | |||
| 247 | + | if e.dropped { | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | e.dropped = true | |||
| 251 | + | c.lru.Remove(e.elem) | |||
| 252 | + | delete(c.entries, e.dir) | |||
| 253 | + | if e.users == 0 { | |||
| 254 | + | go e.closeRepo() // not under the mutex: Close takes the repo's own | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // evictLocked drops the least recently used entries beyond the bound. | |||
| 259 | + | func (c *cache) evictLocked() { | |||
| 260 | + | for c.lru.Len() > c.max { | |||
| 261 | + | c.dropLocked(c.lru.Back().Value.(*entry)) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // forgetEntry drops e if it is still the entry of its directory. | |||
| 266 | + | func (c *cache) forgetEntry(e *entry) { | |||
| 267 | + | c.mu.Lock() | |||
| 268 | + | defer c.mu.Unlock() | |||
| 269 | + | if c.entries[e.dir] == e { | |||
| 270 | + | c.dropLocked(e) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // forget drops the entry of dir. | |||
| 275 | + | func (c *cache) forget(dir string) { | |||
| 276 | + | c.mu.Lock() | |||
| 277 | + | defer c.mu.Unlock() | |||
| 278 | + | if e := c.entries[dir]; e != nil { | |||
| 279 | + | c.dropLocked(e) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // close drops every entry; later gets fail with ErrClosed. | |||
| 284 | + | func (c *cache) close() { | |||
| 285 | + | c.mu.Lock() | |||
| 286 | + | defer c.mu.Unlock() | |||
| 287 | + | c.closed = true | |||
| 288 | + | for _, e := range c.entries { | |||
| 289 | + | c.dropLocked(e) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // size returns the number of cached repositories. | |||
| 294 | + | func (c *cache) size() int { | |||
| 295 | + | c.mu.Lock() | |||
| 296 | + | defer c.mu.Unlock() | |||
| 297 | + | return len(c.entries) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func identity(fi fs.FileInfo) (dev, ino uint64) { | |||
| 301 | + | if st, ok := fi.Sys().(*syscall.Stat_t); ok { | |||
| 302 | + | return uint64(st.Dev), st.Ino //nolint:unconvert // Dev is uint32 on some platforms | |||
| 303 | + | } | |||
| 304 | + | return 0, 0 | |||
| 305 | + | } | |||
A internal/adapters/ermitegit/cache_test.go +206 -0
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sync" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zerowrap" | |||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | "bnema.dev/ermite/internal/readertest" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func commitOf(t *testing.T, data, repo string) *readertest.Fixture { | |||
| 25 | + | t.Helper() | |||
| 26 | + | f := readertest.NewFixture(t, data, repo) | |||
| 27 | + | f.Write("a.txt", repo+"\n") | |||
| 28 | + | f.Commit("commit of " + repo) | |||
| 29 | + | return f | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestCacheIsBoundedAndEvictsLeastRecentlyUsed(t *testing.T) { | |||
| 33 | + | ctx := context.Background() | |||
| 34 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 3 }) | |||
| 35 | + | var fx []*readertest.Fixture | |||
| 36 | + | for i := range 6 { | |||
| 37 | + | fx = append(fx, commitOf(t, data, fmt.Sprintf("r%d", i))) | |||
| 38 | + | } | |||
| 39 | + | for _, f := range fx { | |||
| 40 | + | res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{}) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | require.Equal(t, f.Git("rev-parse", "HEAD"), res.CommitID.String()) | |||
| 43 | + | assert.LessOrEqual(t, a.OpenRepos(), 3) | |||
| 44 | + | } | |||
| 45 | + | assert.Equal(t, 3, a.OpenRepos()) | |||
| 46 | + | // An evicted repository is opened again on the next read. | |||
| 47 | + | res, err := a.ResolveRevision(ctx, fx[0].Name, domain.Revision{}) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, fx[0].Git("rev-parse", "HEAD"), res.CommitID.String()) | |||
| 50 | + | assert.Equal(t, 3, a.OpenRepos()) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // An evicted repository stays usable by the reads that hold it: a blob being | |||
| 54 | + | // streamed is not cut when the cache closes its repository. | |||
| 55 | + | func TestEvictionDoesNotCutAStream(t *testing.T) { | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 1 }) | |||
| 58 | + | f := readertest.NewFixture(t, data, "big") | |||
| 59 | + | content := readertest.RandomBytes(3<<20, 11) | |||
| 60 | + | f.WriteBytes("big.bin", content) | |||
| 61 | + | f.Commit("big") | |||
| 62 | + | other := commitOf(t, data, "other") | |||
| 63 | + | ||||
| 64 | + | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin")) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | head := make([]byte, 1000) | |||
| 67 | + | _, err = rc.Read(head) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | ||||
| 70 | + | _, err = a.ResolveRevision(ctx, other.Name, domain.Revision{}) // evicts "big" | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | assert.Equal(t, 1, a.OpenRepos()) | |||
| 73 | + | ||||
| 74 | + | rest := new(bytes.Buffer) | |||
| 75 | + | _, err = rest.ReadFrom(rc) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NoError(t, rc.Close()) | |||
| 78 | + | assert.True(t, bytes.Equal(content[1000:], rest.Bytes())) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestConcurrentReadsEvictionAndPushes(t *testing.T) { | |||
| 82 | + | ctx := context.Background() | |||
| 83 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 2; c.RefreshEvery = -1 }) | |||
| 84 | + | var fx []*readertest.Fixture | |||
| 85 | + | for i := range 5 { | |||
| 86 | + | fx = append(fx, commitOf(t, data, fmt.Sprintf("c%d", i))) | |||
| 87 | + | } | |||
| 88 | + | var wg sync.WaitGroup | |||
| 89 | + | for w := range 8 { | |||
| 90 | + | wg.Go(func() { | |||
| 91 | + | for i := range 40 { | |||
| 92 | + | f := fx[(w+i)%len(fx)] | |||
| 93 | + | if _, err := a.ListRefs(ctx, f.Name); err != nil { | |||
| 94 | + | t.Error(err) | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5}) | |||
| 98 | + | if err != nil || len(page.Commits) != 1 { | |||
| 99 | + | t.Errorf("%v %v", err, page) | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | if _, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true); err != nil { | |||
| 103 | + | t.Error(err) | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | }) | |||
| 108 | + | } | |||
| 109 | + | wg.Wait() | |||
| 110 | + | a.Close() | |||
| 111 | + | _, err := a.ListRefs(ctx, fx[0].Name) | |||
| 112 | + | assert.ErrorIs(t, err, ermitegit.ErrClosed) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // A repository ermite-git rejects is served by git for every method, and the | |||
| 116 | + | // reason is logged once at info. | |||
| 117 | + | func TestUnsupportedRepositoryFallsBackToGit(t *testing.T) { | |||
| 118 | + | var logs bytes.Buffer | |||
| 119 | + | log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}) | |||
| 120 | + | ctx := zerowrap.WithCtx(context.Background(), log) | |||
| 121 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 122 | + | ||||
| 123 | + | h := readertest.NewHistory(t, data) | |||
| 124 | + | // An alternates file makes the repository unsupported for the library. | |||
| 125 | + | other := t.TempDir() | |||
| 126 | + | require.NoError(t, os.WriteFile(filepath.Join(h.Bare, "objects", "info", "alternates"), []byte(filepath.Join(other, "objects")+"\n"), 0o644)) | |||
| 127 | + | ||||
| 128 | + | for range 3 { | |||
| 129 | + | want, err := g.ListRefs(ctx, h.Name) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | got, err := a.ListRefs(ctx, h.Name) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | assert.Equal(t, want, got) | |||
| 134 | + | page, err := a.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100}) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | assert.Len(t, page.Commits, 6) | |||
| 137 | + | tree, err := a.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, true) | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | assert.NotEmpty(t, tree.Entries) | |||
| 140 | + | } | |||
| 141 | + | assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("served by git")), "logged once: %s", logs.String()) | |||
| 142 | + | assert.Contains(t, logs.String(), `"level":"info"`) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // A repository that becomes unsupported while it is open (a replace ref is | |||
| 146 | + | // pushed) is handed to git from then on. | |||
| 147 | + | func TestRepositoryBecomingUnsupportedFallsBack(t *testing.T) { | |||
| 148 | + | ctx := context.Background() | |||
| 149 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = -1 }) | |||
| 150 | + | h := readertest.NewHistory(t, data) | |||
| 151 | + | _, err := a.ListRefs(ctx, h.Name) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | ||||
| 154 | + | h.Git("replace", h.C2, h.C3) | |||
| 155 | + | want, err := g.ListRefs(ctx, h.Name) | |||
| 156 | + | require.NoError(t, err) | |||
| 157 | + | got, err := a.ListRefs(ctx, h.Name) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | assert.Equal(t, want, got) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Errors that are not ErrUnsupported are returned, not hidden by a fallback. | |||
| 163 | + | func TestOtherOpenErrorsAreReturned(t *testing.T) { | |||
| 164 | + | ctx := context.Background() | |||
| 165 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 166 | + | f := commitOf(t, data, "broken") | |||
| 167 | + | // A corrupt packed-refs is neither unsupported nor missing. | |||
| 168 | + | require.NoError(t, os.WriteFile(filepath.Join(f.Bare, "packed-refs"), []byte("not a packed-refs file\n"), 0o644)) | |||
| 169 | + | _, err := a.ListRefs(ctx, f.Name) | |||
| 170 | + | require.Error(t, err) | |||
| 171 | + | assert.NotErrorIs(t, err, domain.ErrNotFound) | |||
| 172 | + | ||||
| 173 | + | _, err = a.ListRefs(ctx, readertest.RepoName(t, "ghost")) | |||
| 174 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestCancelledContext(t *testing.T) { | |||
| 178 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 179 | + | f := commitOf(t, data, "ctx") | |||
| 180 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 181 | + | cancel() | |||
| 182 | + | _, err := a.ListRefs(ctx, f.Name) | |||
| 183 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 184 | + | _, err = a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5}) | |||
| 185 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 186 | + | _, _, err = a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "a.txt")) | |||
| 187 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // A commit in an encoding other than UTF-8 is converted by git, not by the | |||
| 191 | + | // library: the adapter hands it to the CLI reader even when ermite-git is | |||
| 192 | + | // routed, and the answer is the one of git. | |||
| 193 | + | func TestEncodedCommitIsServedByGit(t *testing.T) { | |||
| 194 | + | ctx := context.Background() | |||
| 195 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 196 | + | f := readertest.NewFixture(t, data, "enc") | |||
| 197 | + | f.Write("a.txt", "a\n") | |||
| 198 | + | f.Git("add", "-A") | |||
| 199 | + | f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "caf\xe9") | |||
| 200 | + | want, err := g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5}) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | got, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5}) | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | assert.Equal(t, want, got) | |||
| 205 | + | assert.Equal(t, "café", got.Commits[0].Message) | |||
| 206 | + | } | |||
A internal/adapters/ermitegit/compare_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 9 | + | "bnema.dev/ermite/internal/readertest" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestCompareWithCLI asks the CLI reader and the adapter, with every method on | |||
| 13 | + | // ermite-git, the same questions about each fixture and requires the same | |||
| 14 | + | // answers, errors included. | |||
| 15 | + | func TestCompareWithCLI(t *testing.T) { | |||
| 16 | + | for _, c := range readertest.Cases() { | |||
| 17 | + | t.Run(c.Name, func(t *testing.T) { | |||
| 18 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 19 | + | f := c.Build(t, data) | |||
| 20 | + | readertest.Compare(t, g, a, f.Name, f.Bare, readertest.SmallBudget) | |||
| 21 | + | }) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // TestCompareDefaultRoutes is the comparison through the routes of production. | |||
| 26 | + | func TestCompareDefaultRoutes(t *testing.T) { | |||
| 27 | + | for _, c := range readertest.Cases() { | |||
| 28 | + | t.Run(c.Name, func(t *testing.T) { | |||
| 29 | + | a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil) | |||
| 30 | + | f := c.Build(t, data) | |||
| 31 | + | readertest.Compare(t, g, a, f.Name, f.Bare, readertest.SmallBudget) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // TestCompareWithCLIOnBenchRepo is the same comparison on a real repository | |||
| 37 | + | // (ERMITE_GIT_BENCH_REPO, for instance lazygit): clone, maintenance as ermite | |||
| 38 | + | // runs it, then the questions on refs, trees with last commits, blobs and | |||
| 39 | + | // pages of history. | |||
| 40 | + | func TestCompareWithCLIOnBenchRepo(t *testing.T) { | |||
| 41 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 42 | + | repo := cloneBench(t, g, data) | |||
| 43 | + | readertest.Compare(t, g, a, repo.name, repo.dir, readertest.LargeBudget) | |||
| 44 | + | } | |||
A internal/adapters/ermitegit/conformance_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 9 | + | "bnema.dev/ermite/internal/ports" | |||
| 10 | + | "bnema.dev/ermite/internal/readertest" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // TestConformance runs the shared RepoReader table against the adapter with | |||
| 14 | + | // every method sent to ermite-git, whatever DefaultRoutes says: a method may | |||
| 15 | + | // only be routed to ermite-git if it passes here. | |||
| 16 | + | func TestConformance(t *testing.T) { | |||
| 17 | + | readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) { | |||
| 18 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |||
| 19 | + | return a, data | |||
| 20 | + | }) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // TestConformanceDefaultRoutes runs the same table through the routes of | |||
| 24 | + | // production, where some methods are served by the CLI reader. | |||
| 25 | + | func TestConformanceDefaultRoutes(t *testing.T) { | |||
| 26 | + | readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) { | |||
| 27 | + | a, _, data := newAdapter(t, ermitegit.DefaultRoutes(), nil) | |||
| 28 | + | return a, data | |||
| 29 | + | }) | |||
| 30 | + | } | |||
A internal/adapters/ermitegit/convert.go +517 -0
| @@ -0,0 +1,517 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | git "bnema.dev/ermite-git" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | const ( | |||
| 20 | + | headsPrefix = "refs/heads/" | |||
| 21 | + | tagsPrefix = "refs/tags/" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObjectID(id.String()) } | |||
| 25 | + | ||||
| 26 | + | // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag | |||
| 27 | + | // follows to its commit when it leads to one; HEAD takes the ids of its | |||
| 28 | + | // branch or tag, and only the name of an unborn branch. | |||
| 29 | + | // | |||
| 30 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 31 | + | func listRefs(r *git.Repo) ([]domain.Ref, error) { | |||
| 32 | + | var refs []domain.Ref | |||
| 33 | + | byName := make(map[string]domain.Ref) | |||
| 34 | + | for _, prefix := range [...]string{headsPrefix, tagsPrefix} { | |||
| 35 | + | kind := domain.RefKindBranch | |||
| 36 | + | if prefix == tagsPrefix { | |||
| 37 | + | kind = domain.RefKindTag | |||
| 38 | + | } | |||
| 39 | + | for ref, err := range r.Refs(prefix) { | |||
| 40 | + | if err != nil { | |||
| 41 | + | return nil, fmt.Errorf("list refs: %w", err) | |||
| 42 | + | } | |||
| 43 | + | out := domain.Ref{Name: ref.Name, ShortName: ref.Name[len(prefix):], Kind: kind} | |||
| 44 | + | var err2 error | |||
| 45 | + | if out.ID, err2 = domainID(ref.ID); err2 != nil { | |||
| 46 | + | return nil, err2 | |||
| 47 | + | } | |||
| 48 | + | commit, err := r.PeelRef(ref) | |||
| 49 | + | switch { | |||
| 50 | + | case err == nil: | |||
| 51 | + | if out.CommitID, err2 = domainID(commit); err2 != nil { | |||
| 52 | + | return nil, err2 | |||
| 53 | + | } | |||
| 54 | + | case errors.Is(err, git.ErrWrongType): | |||
| 55 | + | // A tag of a tree or a blob has no commit. | |||
| 56 | + | default: | |||
| 57 | + | return nil, fmt.Errorf("follow %s: %w", ref.Name, err) | |||
| 58 | + | } | |||
| 59 | + | refs = append(refs, out) | |||
| 60 | + | byName[out.Name] = out | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead} | |||
| 64 | + | target, _, err := r.Head() | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, fmt.Errorf("read HEAD: %w", err) | |||
| 67 | + | } | |||
| 68 | + | if b, ok := byName[target]; ok && target != "" { | |||
| 69 | + | head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID | |||
| 70 | + | } else if short, ok := cutPrefix(target, headsPrefix); ok { | |||
| 71 | + | head.ShortName = short // unborn branch | |||
| 72 | + | } | |||
| 73 | + | return append([]domain.Ref{head}, refs...), nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func cutPrefix(s, prefix string) (string, bool) { | |||
| 77 | + | if len(s) >= len(prefix) && s[:len(prefix)] == prefix { | |||
| 78 | + | return s[len(prefix):], true | |||
| 79 | + | } | |||
| 80 | + | return s, false | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // resolved is a revision resolved to a commit. | |||
| 84 | + | type resolved struct { | |||
| 85 | + | commit git.ObjectID | |||
| 86 | + | ref string | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (r resolved) domain() (domain.ResolvedRevision, error) { | |||
| 90 | + | id, err := domainID(r.commit) | |||
| 91 | + | return domain.ResolvedRevision{CommitID: id, Ref: r.ref}, err | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // peelRef reads a ref and peels it to a commit. ok is false when the ref does | |||
| 95 | + | // not exist or does not lead to a commit. | |||
| 96 | + | func peelRef(r *git.Repo, name string) (_ git.ObjectID, ok bool, _ error) { | |||
| 97 | + | id, err := r.ResolveRef(name) | |||
| 98 | + | if errors.Is(err, git.ErrNotFound) { | |||
| 99 | + | return git.ObjectID{}, false, nil | |||
| 100 | + | } | |||
| 101 | + | if err != nil { | |||
| 102 | + | return git.ObjectID{}, false, err | |||
| 103 | + | } | |||
| 104 | + | return peelID(r, id) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) { | |||
| 108 | + | commit, err := r.PeelToCommit(id) | |||
| 109 | + | if errors.Is(err, git.ErrNotFound) || errors.Is(err, git.ErrWrongType) { | |||
| 110 | + | return git.ObjectID{}, false, nil | |||
| 111 | + | } | |||
| 112 | + | if err != nil { | |||
| 113 | + | return git.ObjectID{}, false, err | |||
| 114 | + | } | |||
| 115 | + | return commit, true, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // resolveRevision tries the candidates of gitcli in the same order, which | |||
| 119 | + | // the port documents: HEAD is HEAD; a full object id is an object before it | |||
| 120 | + | // is a ref; then the branch, then the tag; then a full ref name and an | |||
| 121 | + | // abbreviated id as written. It does not use Repo.Resolve, whose order is | |||
| 122 | + | // git's own (tags before branches, no ref for a missing full id). | |||
| 123 | + | // | |||
| 124 | + | //hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract | |||
| 125 | + | func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) { | |||
| 126 | + | s := rev.String() | |||
| 127 | + | if rev.IsHead() || s == "HEAD" { | |||
| 128 | + | if c, ok, err := peelRef(r, "HEAD"); err != nil || ok { | |||
| 129 | + | return resolved{commit: c}, err | |||
| 130 | + | } | |||
| 131 | + | return resolved{}, notFound(rev) | |||
| 132 | + | } | |||
| 133 | + | if rev.IsObjectID() { | |||
| 134 | + | id, err := git.ParseID(s) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return resolved{}, err | |||
| 137 | + | } | |||
| 138 | + | if c, ok, err := peelID(r, id); err != nil || ok { | |||
| 139 | + | return resolved{commit: c}, err | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} { | |||
| 143 | + | if c, ok, err := peelRef(r, name); err != nil || ok { | |||
| 144 | + | return resolved{commit: c, ref: name}, err | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | switch { | |||
| 148 | + | case len(s) > 5 && s[:5] == "refs/": | |||
| 149 | + | if c, ok, err := peelRef(r, s); err != nil || ok { | |||
| 150 | + | return resolved{commit: c, ref: s}, err | |||
| 151 | + | } | |||
| 152 | + | case rev.IsAbbrevID(): | |||
| 153 | + | id, err := r.ResolveCommitPrefix(s) | |||
| 154 | + | if err == nil { | |||
| 155 | + | // The prefix may name an annotated tag: git peels it. | |||
| 156 | + | id, err = r.PeelToCommit(id) | |||
| 157 | + | } | |||
| 158 | + | switch { | |||
| 159 | + | case err == nil: | |||
| 160 | + | return resolved{commit: id}, nil | |||
| 161 | + | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType): | |||
| 162 | + | default: | |||
| 163 | + | return resolved{}, err | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | return resolved{}, notFound(rev) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func notFound(rev domain.Revision) error { | |||
| 170 | + | return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // canonMode is the mode git reports for a tree entry: legacy permission bits | |||
| 174 | + | // are normalized (tree-walk.c canon_mode). | |||
| 175 | + | func canonMode(m git.Mode) uint32 { | |||
| 176 | + | switch { | |||
| 177 | + | case m.IsRegular(): | |||
| 178 | + | if m&0o100 != 0 { | |||
| 179 | + | return 0o100755 | |||
| 180 | + | } | |||
| 181 | + | return 0o100644 | |||
| 182 | + | case m.IsSymlink(): | |||
| 183 | + | return 0o120000 | |||
| 184 | + | case m.IsTree(): | |||
| 185 | + | return 0o040000 | |||
| 186 | + | } | |||
| 187 | + | return 0o160000 | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // commitTree resolves rev and reads the root tree of the commit. | |||
| 191 | + | func commitTree(r *git.Repo, rev domain.Revision) (domain.ObjectID, git.ObjectID, error) { | |||
| 192 | + | res, err := resolveRevision(r, rev) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return domain.ObjectID{}, git.ObjectID{}, err | |||
| 195 | + | } | |||
| 196 | + | var c git.Commit | |||
| 197 | + | if err := r.ReadCommit(res.commit, &c); err != nil { | |||
| 198 | + | return domain.ObjectID{}, git.ObjectID{}, err | |||
| 199 | + | } | |||
| 200 | + | id, err := domainID(res.commit) | |||
| 201 | + | return id, c.Tree, err | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // lookup finds the entry at a path of a commit tree. A path that does not | |||
| 205 | + | // exist, and a submodule (whose commit is not in the repository), are | |||
| 206 | + | // domain.ErrNotFound like in gitcli. | |||
| 207 | + | func lookup(r *git.Repo, tree git.ObjectID, path domain.TreePath) (git.Entry, error) { | |||
| 208 | + | e, err := r.LookupPath(tree, path.String()) | |||
| 209 | + | if errors.Is(err, git.ErrNotFound) { | |||
| 210 | + | return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound) | |||
| 211 | + | } | |||
| 212 | + | if err != nil { | |||
| 213 | + | return git.Entry{}, err | |||
| 214 | + | } | |||
| 215 | + | if e.Mode.IsGitlink() { | |||
| 216 | + | return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound) | |||
| 217 | + | } | |||
| 218 | + | return e, nil | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // getTree lists a directory. | |||
| 222 | + | // | |||
| 223 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 224 | + | func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) { | |||
| 225 | + | commit, root, err := commitTree(r, rev) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return domain.Tree{}, err | |||
| 228 | + | } | |||
| 229 | + | dir, err := lookup(r, root, path) | |||
| 230 | + | if err != nil { | |||
| 231 | + | return domain.Tree{}, err | |||
| 232 | + | } | |||
| 233 | + | if !dir.Mode.IsTree() { | |||
| 234 | + | return domain.Tree{}, domain.ErrNotDirectory | |||
| 235 | + | } | |||
| 236 | + | treeID, err := domainID(dir.ID) | |||
| 237 | + | if err != nil { | |||
| 238 | + | return domain.Tree{}, err | |||
| 239 | + | } | |||
| 240 | + | tree := domain.Tree{CommitID: commit, ID: treeID} | |||
| 241 | + | // The ids, the names and the paths of all the entries share one backing | |||
| 242 | + | // string, cut into substrings. | |||
| 243 | + | type span struct { | |||
| 244 | + | name, path, id [2]int | |||
| 245 | + | mode uint32 | |||
| 246 | + | kind domain.EntryKind | |||
| 247 | + | } | |||
| 248 | + | var ( | |||
| 249 | + | spans []span | |||
| 250 | + | buf []byte | |||
| 251 | + | bad error | |||
| 252 | + | ) | |||
| 253 | + | prefix := path.String() | |||
| 254 | + | err = r.ReadTree(dir.ID, func(e git.Entry) bool { | |||
| 255 | + | mode := canonMode(e.Mode) | |||
| 256 | + | kind := domain.EntryKindOf(mode) | |||
| 257 | + | if kind == "" { | |||
| 258 | + | bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name) | |||
| 259 | + | return false | |||
| 260 | + | } | |||
| 261 | + | var sp span | |||
| 262 | + | sp.mode, sp.kind = mode, kind | |||
| 263 | + | sp.id[0] = len(buf) | |||
| 264 | + | buf = e.ID.AppendHex(buf) | |||
| 265 | + | sp.id[1] = len(buf) | |||
| 266 | + | sp.name[0] = len(buf) | |||
| 267 | + | buf = append(buf, e.Name...) | |||
| 268 | + | sp.name[1] = len(buf) | |||
| 269 | + | if prefix == "" { | |||
| 270 | + | sp.path = sp.name | |||
| 271 | + | } else { | |||
| 272 | + | sp.path[0] = len(buf) | |||
| 273 | + | buf = append(buf, prefix...) | |||
| 274 | + | buf = append(buf, '/') | |||
| 275 | + | buf = append(buf, e.Name...) | |||
| 276 | + | sp.path[1] = len(buf) | |||
| 277 | + | } | |||
| 278 | + | spans = append(spans, sp) | |||
| 279 | + | return true | |||
| 280 | + | }) | |||
| 281 | + | if err == nil { | |||
| 282 | + | err = bad | |||
| 283 | + | } | |||
| 284 | + | if err != nil { | |||
| 285 | + | return domain.Tree{}, fmt.Errorf("list tree %q: %w", path, err) | |||
| 286 | + | } | |||
| 287 | + | text := string(buf) | |||
| 288 | + | tree.Entries = make([]domain.TreeEntry, len(spans)) | |||
| 289 | + | for i, sp := range spans { | |||
| 290 | + | id, err := domain.ParseObjectID(text[sp.id[0]:sp.id[1]]) | |||
| 291 | + | if err != nil { | |||
| 292 | + | return domain.Tree{}, err | |||
| 293 | + | } | |||
| 294 | + | tree.Entries[i] = domain.TreeEntry{ | |||
| 295 | + | Name: text[sp.name[0]:sp.name[1]], Path: text[sp.path[0]:sp.path[1]], | |||
| 296 | + | Kind: sp.kind, Mode: sp.mode, ID: id, | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | domain.SortTreeEntries(tree.Entries) | |||
| 300 | + | tree.BoundTree() | |||
| 301 | + | // The size of a file costs a header read: only for the entries that stay. | |||
| 302 | + | for i, e := range tree.Entries { | |||
| 303 | + | if e.Kind == domain.EntryDirectory || e.Kind == domain.EntrySubmodule { | |||
| 304 | + | continue | |||
| 305 | + | } | |||
| 306 | + | id, err := git.ParseID(e.ID.String()) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return domain.Tree{}, err | |||
| 309 | + | } | |||
| 310 | + | _, size, err := r.ObjectHeader(id) | |||
| 311 | + | if err != nil { | |||
| 312 | + | return domain.Tree{}, fmt.Errorf("size of %q: %w", e.Path, err) | |||
| 313 | + | } | |||
| 314 | + | tree.Entries[i].Size = size | |||
| 315 | + | } | |||
| 316 | + | if includeLast { | |||
| 317 | + | if err := fillLastCommits(ctx, r, commit, path, &tree); err != nil { | |||
| 318 | + | return domain.Tree{}, fmt.Errorf("last commits of %q: %w", path, err) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return tree, nil | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | |||
| 325 | + | // entries with one walk. | |||
| 326 | + | // | |||
| 327 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 328 | + | func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, dir domain.TreePath, tree *domain.Tree) error { | |||
| 329 | + | n := len(tree.Entries) | |||
| 330 | + | if n > domain.MaxLastCommitEntries { | |||
| 331 | + | n = domain.MaxLastCommitEntries | |||
| 332 | + | tree.LastCommitsOmitted = true | |||
| 333 | + | } | |||
| 334 | + | if n == 0 { | |||
| 335 | + | return nil | |||
| 336 | + | } | |||
| 337 | + | start, err := git.ParseID(commit.String()) | |||
| 338 | + | if err != nil { | |||
| 339 | + | return err | |||
| 340 | + | } | |||
| 341 | + | names := make([]string, n) | |||
| 342 | + | for i := range n { | |||
| 343 | + | names[i] = tree.Entries[i].Name | |||
| 344 | + | } | |||
| 345 | + | ids, err := r.LastCommits(ctx, start, dir.String(), names) | |||
| 346 | + | if err != nil { | |||
| 347 | + | return err | |||
| 348 | + | } | |||
| 349 | + | var c git.Commit | |||
| 350 | + | seen := make(map[git.ObjectID]domain.Commit, 8) | |||
| 351 | + | for i, id := range ids { | |||
| 352 | + | if id.IsZero() { | |||
| 353 | + | return fmt.Errorf("no commit touches %q: %w", tree.Entries[i].Path, domain.ErrNotFound) | |||
| 354 | + | } | |||
| 355 | + | dc, ok := seen[id] | |||
| 356 | + | if !ok { | |||
| 357 | + | if err := r.ReadCommit(id, &c); err != nil { | |||
| 358 | + | return err | |||
| 359 | + | } | |||
| 360 | + | if dc, err = convertCommit(&c); err != nil { | |||
| 361 | + | return err | |||
| 362 | + | } | |||
| 363 | + | seen[id] = dc | |||
| 364 | + | } | |||
| 365 | + | cp := dc | |||
| 366 | + | tree.Entries[i].LastCommit = &cp | |||
| 367 | + | } | |||
| 368 | + | return nil | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // convertCommit builds a domain commit. A commit in another encoding than | |||
| 372 | + | // UTF-8 is converted by git and not by the library: errFallback. | |||
| 373 | + | // | |||
| 374 | + | // The ids, the names, the emails and the message share one backing string, cut | |||
| 375 | + | // into substrings: one allocation for the text of a commit instead of one per | |||
| 376 | + | // field. | |||
| 377 | + | // | |||
| 378 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 379 | + | func convertCommit(c *git.Commit) (domain.Commit, error) { | |||
| 380 | + | if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) { | |||
| 381 | + | return domain.Commit{}, errFallback | |||
| 382 | + | } | |||
| 383 | + | buf := make([]byte, 0, 40*(1+len(c.Parents))+len(c.Author.Name)+len(c.Author.Email)+ | |||
| 384 | + | len(c.Committer.Name)+len(c.Committer.Email)+len(c.Message)) | |||
| 385 | + | buf = c.ID.AppendHex(buf) | |||
| 386 | + | for _, p := range c.Parents { | |||
| 387 | + | buf = p.AppendHex(buf) | |||
| 388 | + | } | |||
| 389 | + | end := [5]int{} | |||
| 390 | + | for i, f := range [...][]byte{c.Author.Name, c.Author.Email, c.Committer.Name, c.Committer.Email, c.Message} { | |||
| 391 | + | buf = append(buf, f...) | |||
| 392 | + | end[i] = len(buf) | |||
| 393 | + | } | |||
| 394 | + | text := string(buf) | |||
| 395 | + | ||||
| 396 | + | id, err := domain.ParseObjectID(text[:40]) | |||
| 397 | + | if err != nil { | |||
| 398 | + | return domain.Commit{}, err | |||
| 399 | + | } | |||
| 400 | + | out := domain.Commit{ID: id} | |||
| 401 | + | if len(c.Parents) > 0 { | |||
| 402 | + | out.Parents = make([]domain.ObjectID, len(c.Parents)) | |||
| 403 | + | for i := range c.Parents { | |||
| 404 | + | if out.Parents[i], err = domain.ParseObjectID(text[40*(i+1) : 40*(i+2)]); err != nil { | |||
| 405 | + | return domain.Commit{}, err | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | at := 40 * (1 + len(c.Parents)) | |||
| 410 | + | out.Author = domain.Signature{ | |||
| 411 | + | Name: domain.ValidGitText(text[at:end[0]]), Email: domain.ValidGitText(text[end[0]:end[1]]), | |||
| 412 | + | Time: time.Unix(c.Author.Time, 0).UTC(), | |||
| 413 | + | } | |||
| 414 | + | out.Committer = domain.Signature{ | |||
| 415 | + | Name: domain.ValidGitText(text[end[1]:end[2]]), Email: domain.ValidGitText(text[end[2]:end[3]]), | |||
| 416 | + | Time: time.Unix(c.Committer.Time, 0).UTC(), | |||
| 417 | + | } | |||
| 418 | + | out.Message, out.Summary = domain.SplitCommitMessage(text[end[3]:]) | |||
| 419 | + | return out, nil | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | func isUTF8Label(b []byte) bool { | |||
| 423 | + | return bytes.EqualFold(b, []byte("utf-8")) || bytes.EqualFold(b, []byte("utf8")) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // listCommits returns a page of history, one commit more than the page to | |||
| 427 | + | // know whether the history goes on. | |||
| 428 | + | func listCommits(ctx context.Context, r *git.Repo, q ports.CommitQuery) (ports.CommitPage, error) { | |||
| 429 | + | res, err := resolveRevision(r, q.Revision) | |||
| 430 | + | if err != nil { | |||
| 431 | + | return ports.CommitPage{}, err | |||
| 432 | + | } | |||
| 433 | + | head, err := res.domain() | |||
| 434 | + | if err != nil { | |||
| 435 | + | return ports.CommitPage{}, err | |||
| 436 | + | } | |||
| 437 | + | page := ports.CommitPage{Head: head.CommitID, Commits: make([]domain.Commit, 0, min(q.Limit, 64))} | |||
| 438 | + | opts := git.WalkOptions{Skip: q.Skip, Limit: q.Limit + 1, Path: q.Path.String()} | |||
| 439 | + | for c, err := range r.Walk(ctx, res.commit, opts) { | |||
| 440 | + | if err != nil { | |||
| 441 | + | return ports.CommitPage{}, err | |||
| 442 | + | } | |||
| 443 | + | if len(page.Commits) == q.Limit { | |||
| 444 | + | page.HasMore = true | |||
| 445 | + | break | |||
| 446 | + | } | |||
| 447 | + | dc, err := convertCommit(c) | |||
| 448 | + | if err != nil { | |||
| 449 | + | return ports.CommitPage{}, err | |||
| 450 | + | } | |||
| 451 | + | page.Commits = append(page.Commits, dc) | |||
| 452 | + | } | |||
| 453 | + | if len(page.Commits) == 0 { | |||
| 454 | + | page.Commits = nil // like gitcli: an empty page has no slice | |||
| 455 | + | } | |||
| 456 | + | return page, nil | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // openBlob describes the file at path and opens its content. The first | |||
| 460 | + | // BinarySniffLen bytes are read to decide Binary and MediaType, then given | |||
| 461 | + | // back to the reader. | |||
| 462 | + | // | |||
| 463 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 464 | + | func openBlob(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 465 | + | if path.IsRoot() { | |||
| 466 | + | return domain.BlobInfo{}, nil, domain.ErrNotFile | |||
| 467 | + | } | |||
| 468 | + | _, root, err := commitTree(r, rev) | |||
| 469 | + | if err != nil { | |||
| 470 | + | return domain.BlobInfo{}, nil, err | |||
| 471 | + | } | |||
| 472 | + | e, err := lookup(r, root, path) | |||
| 473 | + | if err != nil { | |||
| 474 | + | return domain.BlobInfo{}, nil, err | |||
| 475 | + | } | |||
| 476 | + | if e.Mode.IsTree() { | |||
| 477 | + | return domain.BlobInfo{}, nil, domain.ErrNotFile | |||
| 478 | + | } | |||
| 479 | + | id, err := domainID(e.ID) | |||
| 480 | + | if err != nil { | |||
| 481 | + | return domain.BlobInfo{}, nil, err | |||
| 482 | + | } | |||
| 483 | + | size, rc, err := r.OpenBlob(e.ID) | |||
| 484 | + | if err != nil { | |||
| 485 | + | return domain.BlobInfo{}, nil, err | |||
| 486 | + | } | |||
| 487 | + | info := domain.BlobInfo{ID: id, Path: path.String(), Size: size, Mode: canonMode(e.Mode)} | |||
| 488 | + | head := make([]byte, min(size, int64(domain.BinarySniffLen))) | |||
| 489 | + | if _, err := io.ReadFull(rc, head); err != nil { | |||
| 490 | + | _ = rc.Close() | |||
| 491 | + | return domain.BlobInfo{}, nil, fmt.Errorf("read blob: %w", err) | |||
| 492 | + | } | |||
| 493 | + | info.SniffContent(head) | |||
| 494 | + | return info, &blobReader{ctx: ctx, head: head, rc: rc}, nil | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | // blobReader replays the bytes read to sniff the content, then the rest of | |||
| 498 | + | // the blob. It stops when the context ends, as a gitcli stream does. | |||
| 499 | + | type blobReader struct { | |||
| 500 | + | ctx context.Context | |||
| 501 | + | head []byte | |||
| 502 | + | rc io.ReadCloser | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | func (b *blobReader) Read(p []byte) (int, error) { | |||
| 506 | + | if err := b.ctx.Err(); err != nil { | |||
| 507 | + | return 0, err | |||
| 508 | + | } | |||
| 509 | + | if len(b.head) > 0 { | |||
| 510 | + | n := copy(p, b.head) | |||
| 511 | + | b.head = b.head[n:] | |||
| 512 | + | return n, nil | |||
| 513 | + | } | |||
| 514 | + | return b.rc.Read(p) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func (b *blobReader) Close() error { return b.rc.Close() } | |||
A internal/adapters/ermitegit/ermitegit.go +338 -0
| @@ -0,0 +1,338 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package ermitegit implements ports.RepoReader over the ermite-git library. | |||
| 4 | + | // | |||
| 5 | + | // The adapter wraps the gitcli reader. Each method has a route, a constant | |||
| 6 | + | // below: the methods that match gitcli in the shared conformance table and | |||
| 7 | + | // beat it in the benchmarks are served by ermite-git, the others (and | |||
| 8 | + | // GetCommit) by the wrapped reader. A repository that ermite-git cannot read | |||
| 9 | + | // (ErrUnsupported: SHA-256, reftable, alternates, replace refs...) is served | |||
| 10 | + | // by the wrapped reader for every method. | |||
| 11 | + | // | |||
| 12 | + | // Any other error of the library is returned, never hidden by a fallback. | |||
| 13 | + | // The exceptions are decisions about the data, not failures: a commit whose | |||
| 14 | + | // message is not in UTF-8 (git converts it, the library does not) and an | |||
| 15 | + | // object larger than the library's in-memory bound are read by the wrapped | |||
| 16 | + | // reader. | |||
| 17 | + | // | |||
| 18 | + | // Freshness: a read that begins after a push has returned sees the push, | |||
| 19 | + | // whichever path made it, because the library reads refs from disk on each | |||
| 20 | + | // call (loose refs always, packed-refs after a stat) and rescans the packs | |||
| 21 | + | // when an object is missing (TestFreshness proves it with no Refresh at all). | |||
| 22 | + | // The adapter still calls Refresh, at most once per Config.RefreshEvery for | |||
| 23 | + | // each repository, to release replaced packs, pick up a new commit-graph and | |||
| 24 | + | // notice a repository that stopped being supported; a stat-based Refresh is | |||
| 25 | + | // 20 µs and 34 allocations, too much to pay on every read of a method that | |||
| 26 | + | // itself costs 20 µs. The cache of open repositories is bounded (LRU); | |||
| 27 | + | // gitcli's Trash tells the adapter to drop a deleted repository (see Forget). | |||
| 28 | + | package ermitegit | |||
| 29 | + | ||||
| 30 | + | import ( | |||
| 31 | + | "context" | |||
| 32 | + | "errors" | |||
| 33 | + | "fmt" | |||
| 34 | + | "io" | |||
| 35 | + | "sync" | |||
| 36 | + | "time" | |||
| 37 | + | ||||
| 38 | + | git "bnema.dev/ermite-git" | |||
| 39 | + | "github.com/bnema/zerowrap" | |||
| 40 | + | ||||
| 41 | + | "bnema.dev/ermite/internal/domain" | |||
| 42 | + | "bnema.dev/ermite/internal/ports" | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | var _ ports.RepoReader = (*Adapter)(nil) | |||
| 46 | + | ||||
| 47 | + | // ErrClosed is returned by reads after Close. | |||
| 48 | + | var ErrClosed = errors.New("ermitegit: reader is closed") | |||
| 49 | + | ||||
| 50 | + | // Backend says which reader serves a method. | |||
| 51 | + | type Backend uint8 | |||
| 52 | + | ||||
| 53 | + | // Backends. | |||
| 54 | + | const ( | |||
| 55 | + | // CLI is the git binary, through the gitcli reader. | |||
| 56 | + | CLI Backend = iota | |||
| 57 | + | // ErmiteGit is the ermite-git library. | |||
| 58 | + | ErmiteGit | |||
| 59 | + | ) | |||
| 60 | + | ||||
| 61 | + | // String returns "cli" or "ermite-git". | |||
| 62 | + | func (b Backend) String() string { | |||
| 63 | + | if b == ErmiteGit { | |||
| 64 | + | return "ermite-git" | |||
| 65 | + | } | |||
| 66 | + | return "cli" | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Routes says which reader serves each method. GetCommit is not here: it is | |||
| 70 | + | // always served by the wrapped reader. | |||
| 71 | + | type Routes struct { | |||
| 72 | + | ListRefs Backend | |||
| 73 | + | // ResolveRevision is a revision that is a name or a full id. | |||
| 74 | + | ResolveRevision Backend | |||
| 75 | + | // ResolveRevisionAbbrev is a revision that is an abbreviated id. | |||
| 76 | + | ResolveRevisionAbbrev Backend | |||
| 77 | + | ResolveRefPath Backend | |||
| 78 | + | // GetTree is a directory listing without last commits. | |||
| 79 | + | GetTree Backend | |||
| 80 | + | // GetTreeLastCommits is a listing with the last commit of each entry. | |||
| 81 | + | GetTreeLastCommits Backend | |||
| 82 | + | OpenBlob Backend | |||
| 83 | + | // ListCommits is a page of history of a revision. | |||
| 84 | + | ListCommits Backend | |||
| 85 | + | // ListCommitsPath is a page of the history of a file or a directory. | |||
| 86 | + | ListCommitsPath Backend | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // AllRoutes returns routes that send every method to b. The tests and the | |||
| 90 | + | // benchmarks use it to exercise a backend whatever the production routes are. | |||
| 91 | + | func AllRoutes(b Backend) Routes { | |||
| 92 | + | return Routes{b, b, b, b, b, b, b, b, b} | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Config configures an Adapter. | |||
| 96 | + | type Config struct { | |||
| 97 | + | // Fallback serves the methods that are not routed to ermite-git and the | |||
| 98 | + | // repositories ermite-git cannot read. Required. | |||
| 99 | + | Fallback ports.RepoReader | |||
| 100 | + | // RepoDir returns the directory of a repository. It does not check that it | |||
| 101 | + | // exists. Required. | |||
| 102 | + | RepoDir func(domain.RepoName) string | |||
| 103 | + | // RefreshEvery is the longest a repository goes without a Refresh of the | |||
| 104 | + | // library while it is read. Zero means DefaultRefreshEvery; a negative | |||
| 105 | + | // value refreshes before every read. It does not affect what a read | |||
| 106 | + | // sees: see the package comment. | |||
| 107 | + | RefreshEvery time.Duration | |||
| 108 | + | // MaxRepos bounds the repositories kept open; zero means DefaultMaxRepos. | |||
| 109 | + | MaxRepos int | |||
| 110 | + | // Routes overrides DefaultRoutes when not nil. | |||
| 111 | + | Routes *Routes | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Adapter is a ports.RepoReader that routes each method to ermite-git or to | |||
| 115 | + | // the wrapped reader. | |||
| 116 | + | type Adapter struct { | |||
| 117 | + | cli ports.RepoReader | |||
| 118 | + | dirOf func(domain.RepoName) string | |||
| 119 | + | routes Routes | |||
| 120 | + | cache *cache | |||
| 121 | + | ||||
| 122 | + | logged sync.Map // dir -> struct{}: the unsupported repositories already logged | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // New builds an adapter. | |||
| 126 | + | func New(cfg Config) *Adapter { | |||
| 127 | + | routes := DefaultRoutes() | |||
| 128 | + | if cfg.Routes != nil { | |||
| 129 | + | routes = *cfg.Routes | |||
| 130 | + | } | |||
| 131 | + | refreshEvery := cfg.RefreshEvery | |||
| 132 | + | if refreshEvery == 0 { | |||
| 133 | + | refreshEvery = DefaultRefreshEvery | |||
| 134 | + | } | |||
| 135 | + | a := &Adapter{cli: cfg.Fallback, dirOf: cfg.RepoDir, routes: routes} | |||
| 136 | + | a.cache = newCache(cfg.MaxRepos, refreshEvery, a.logUnsupported) | |||
| 137 | + | return a | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Close closes the open repositories. Reads after Close fail with ErrClosed; | |||
| 141 | + | // blob streams already returned stay valid until they are closed. The wrapped | |||
| 142 | + | // reader is not closed. | |||
| 143 | + | func (a *Adapter) Close() { a.cache.close() } | |||
| 144 | + | ||||
| 145 | + | // Forget drops the open repository of dir, which was moved away or deleted. | |||
| 146 | + | func (a *Adapter) Forget(dir string) { | |||
| 147 | + | a.cache.forget(dir) | |||
| 148 | + | a.logged.Delete(dir) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // OpenRepos returns the number of repositories in the cache, for tests. | |||
| 152 | + | func (a *Adapter) OpenRepos() int { return a.cache.size() } | |||
| 153 | + | ||||
| 154 | + | // logUnsupported logs, once per directory, that a repository is served by git. | |||
| 155 | + | func (a *Adapter) logUnsupported(ctx context.Context, dir string, name domain.RepoName, cause error) { | |||
| 156 | + | if _, seen := a.logged.LoadOrStore(dir, struct{}{}); seen { | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | log := zerowrap.FromCtx(ctx) | |||
| 160 | + | log.Info().Str("repo", name.String()).Str("reason", cause.Error()).Msg("repository is not readable by ermite-git, served by git") | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // errFallback makes serve hand the call to the wrapped reader. | |||
| 164 | + | var errFallback = errors.New("ermitegit: served by git") | |||
| 165 | + | ||||
| 166 | + | // serve runs fn on the open repository, or cli when the repository is | |||
| 167 | + | // unsupported, when fn asks for it, or when the library refuses on a bound. | |||
| 168 | + | func serve[T any](ctx context.Context, a *Adapter, repo domain.RepoName, fn func(*git.Repo) (T, error), cli func() (T, error)) (T, error) { | |||
| 169 | + | var zero T | |||
| 170 | + | if err := ctx.Err(); err != nil { | |||
| 171 | + | return zero, err | |||
| 172 | + | } | |||
| 173 | + | h, err := a.cache.get(ctx, repo, a.dirOf(repo)) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return zero, err | |||
| 176 | + | } | |||
| 177 | + | defer h.release() | |||
| 178 | + | if h.repo == nil { | |||
| 179 | + | return cli() | |||
| 180 | + | } | |||
| 181 | + | v, err := fn(h.repo) | |||
| 182 | + | switch { | |||
| 183 | + | case err == nil: | |||
| 184 | + | return v, nil | |||
| 185 | + | case errors.Is(err, errFallback), errors.Is(err, git.ErrTooLarge): | |||
| 186 | + | return cli() | |||
| 187 | + | case errors.Is(err, git.ErrUnsupported): | |||
| 188 | + | h.fallBack(ctx, repo, err) | |||
| 189 | + | return cli() | |||
| 190 | + | } | |||
| 191 | + | return zero, mapErr(ctx, err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // mapErr turns the errors of the library into the ones of the port. A | |||
| 195 | + | // cancelled context stays itself. | |||
| 196 | + | func mapErr(ctx context.Context, err error) error { | |||
| 197 | + | switch { | |||
| 198 | + | case ctx.Err() != nil && errors.Is(err, ctx.Err()): | |||
| 199 | + | return err | |||
| 200 | + | case errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrNotFile), errors.Is(err, domain.ErrNotDirectory): | |||
| 201 | + | return err // already one of the port's | |||
| 202 | + | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous): | |||
| 203 | + | // gitcli reads an ambiguous abbreviation as a missing object. | |||
| 204 | + | return fmt.Errorf("%w: %w", domain.ErrNotFound, err) | |||
| 205 | + | case errors.Is(err, git.ErrInvalidPath): | |||
| 206 | + | return fmt.Errorf("%w: %w", domain.ErrInvalidPath, err) | |||
| 207 | + | case errors.Is(err, git.ErrInvalidID): | |||
| 208 | + | return fmt.Errorf("%w: %w", domain.ErrInvalidObjectID, err) | |||
| 209 | + | case errors.Is(err, git.ErrClosed): | |||
| 210 | + | return fmt.Errorf("%w: %w", ErrClosed, err) | |||
| 211 | + | } | |||
| 212 | + | return err | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // ListRefs lists HEAD, the branches and the tags. | |||
| 216 | + | func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | |||
| 217 | + | cli := func() ([]domain.Ref, error) { return a.cli.ListRefs(ctx, repo) } | |||
| 218 | + | if a.routes.ListRefs == CLI { | |||
| 219 | + | return cli() | |||
| 220 | + | } | |||
| 221 | + | return serve(ctx, a, repo, func(r *git.Repo) ([]domain.Ref, error) { return listRefs(r) }, cli) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // ResolveRevision turns a revision into a commit. | |||
| 225 | + | // | |||
| 226 | + | //hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports | |||
| 227 | + | func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |||
| 228 | + | cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) } | |||
| 229 | + | route := a.routes.ResolveRevision | |||
| 230 | + | if rev.IsAbbrevID() { | |||
| 231 | + | route = a.routes.ResolveRevisionAbbrev | |||
| 232 | + | } | |||
| 233 | + | if route == CLI { | |||
| 234 | + | return cli() | |||
| 235 | + | } | |||
| 236 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.ResolvedRevision, error) { | |||
| 237 | + | res, err := resolveRevision(r, rev) | |||
| 238 | + | if err != nil { | |||
| 239 | + | return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err) | |||
| 240 | + | } | |||
| 241 | + | return res.domain() | |||
| 242 | + | }, cli) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // ResolveRefPath splits a path into a revision and the rest. | |||
| 246 | + | // | |||
| 247 | + | //hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports | |||
| 248 | + | func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | |||
| 249 | + | cli := func() (domain.RefPath, error) { return a.cli.ResolveRefPath(ctx, repo, path) } | |||
| 250 | + | if a.routes.ResolveRefPath == CLI { | |||
| 251 | + | return cli() | |||
| 252 | + | } | |||
| 253 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.RefPath, error) { | |||
| 254 | + | out, err := domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) { | |||
| 255 | + | res, err := resolveRevision(r, rev) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return domain.ResolvedRevision{}, mapErr(ctx, err) | |||
| 258 | + | } | |||
| 259 | + | return res.domain() | |||
| 260 | + | }) | |||
| 261 | + | if err != nil { | |||
| 262 | + | return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err) | |||
| 263 | + | } | |||
| 264 | + | return out, nil | |||
| 265 | + | }, cli) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // GetTree lists a directory. | |||
| 269 | + | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |||
| 270 | + | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | |||
| 271 | + | route := a.routes.GetTree | |||
| 272 | + | if includeLastCommit { | |||
| 273 | + | route = a.routes.GetTreeLastCommits | |||
| 274 | + | } | |||
| 275 | + | if route == CLI { | |||
| 276 | + | return cli() | |||
| 277 | + | } | |||
| 278 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) { | |||
| 279 | + | tree, err := getTree(ctx, r, rev, path, includeLastCommit) | |||
| 280 | + | if err != nil { | |||
| 281 | + | return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err) | |||
| 282 | + | } | |||
| 283 | + | return tree, nil | |||
| 284 | + | }, cli) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // OpenBlob describes a file and returns its content as a stream. | |||
| 288 | + | func (a *Adapter) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |||
| 289 | + | type result struct { | |||
| 290 | + | info domain.BlobInfo | |||
| 291 | + | rc io.ReadCloser | |||
| 292 | + | } | |||
| 293 | + | cli := func() (result, error) { | |||
| 294 | + | info, rc, err := a.cli.OpenBlob(ctx, repo, rev, path) | |||
| 295 | + | return result{info, rc}, err | |||
| 296 | + | } | |||
| 297 | + | if a.routes.OpenBlob == CLI { | |||
| 298 | + | res, err := cli() | |||
| 299 | + | return res.info, res.rc, err | |||
| 300 | + | } | |||
| 301 | + | res, err := serve(ctx, a, repo, func(r *git.Repo) (result, error) { | |||
| 302 | + | info, rc, err := openBlob(ctx, r, rev, path) | |||
| 303 | + | if err != nil { | |||
| 304 | + | return result{}, fmt.Errorf("blob %q of %q: %w", path, repo, err) | |||
| 305 | + | } | |||
| 306 | + | return result{info, rc}, nil | |||
| 307 | + | }, cli) | |||
| 308 | + | return res.info, res.rc, err | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // ListCommits returns a page of history. | |||
| 312 | + | // | |||
| 313 | + | //hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports | |||
| 314 | + | func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) { | |||
| 315 | + | if err := q.Validate(); err != nil { | |||
| 316 | + | return ports.CommitPage{}, err | |||
| 317 | + | } | |||
| 318 | + | cli := func() (ports.CommitPage, error) { return a.cli.ListCommits(ctx, repo, q) } | |||
| 319 | + | route := a.routes.ListCommits | |||
| 320 | + | if !q.Path.IsRoot() { | |||
| 321 | + | route = a.routes.ListCommitsPath | |||
| 322 | + | } | |||
| 323 | + | if route == CLI { | |||
| 324 | + | return cli() | |||
| 325 | + | } | |||
| 326 | + | return serve(ctx, a, repo, func(r *git.Repo) (ports.CommitPage, error) { | |||
| 327 | + | page, err := listCommits(ctx, r, q) | |||
| 328 | + | if err != nil { | |||
| 329 | + | return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err) | |||
| 330 | + | } | |||
| 331 | + | return page, nil | |||
| 332 | + | }, cli) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | // GetCommit is served by the wrapped reader: the diff stays on git. | |||
| 336 | + | func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | |||
| 337 | + | return a.cli.GetCommit(ctx, repo, rev, limits) | |||
| 338 | + | } | |||
A internal/adapters/ermitegit/freshness_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | "bnema.dev/ermite/internal/readertest" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // push sends the work tree's HEAD to the bare repository of the fixture as a | |||
| 24 | + | // push does: git receive-pack writes a pack (or loose objects) and updates | |||
| 25 | + | // the ref, with no help from the reader. | |||
| 26 | + | func push(t *testing.T, f *readertest.Fixture, remote, branch string, loose bool) { | |||
| 27 | + | t.Helper() | |||
| 28 | + | args := []string{"push", "--quiet", remote, "HEAD:refs/heads/" + branch} | |||
| 29 | + | if loose { | |||
| 30 | + | args = append([]string{"-c", "receive.unpackLimit=1000"}, args...) | |||
| 31 | + | } | |||
| 32 | + | cmd := exec.Command("git", args...) | |||
| 33 | + | cmd.Dir = f.Work | |||
| 34 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + f.Work, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_DIR=" + f.Bare} | |||
| 35 | + | out, err := cmd.CombinedOutput() | |||
| 36 | + | require.NoError(t, err, string(out)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // TestFreshness proves that a read that starts after a push sees the push | |||
| 40 | + | // with the adapter's own Refresh disabled (an interval of one hour: only the | |||
| 41 | + | // first read refreshes): new commits in a pack and as loose objects, new | |||
| 42 | + | // branches, a ref that moves, a repack that replaces the packs, a new | |||
| 43 | + | // commit-graph layer, a deleted branch, tags. | |||
| 44 | + | func TestFreshness(t *testing.T) { | |||
| 45 | + | ctx := context.Background() | |||
| 46 | + | a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour }) | |||
| 47 | + | ||||
| 48 | + | // The work tree is an ordinary clone-like repository; "remote" is the | |||
| 49 | + | // bare repository the adapter reads. | |||
| 50 | + | f := readertest.NewFixture(t, data, "pushed") | |||
| 51 | + | bare := f.Bare | |||
| 52 | + | // A separate repository makes the commits and pushes them. | |||
| 53 | + | work := readertest.NewFixture(t, t.TempDir(), "work") | |||
| 54 | + | work.Write("a.txt", "1\n") | |||
| 55 | + | work.Commit("first") | |||
| 56 | + | push(t, work, bare, "main", false) | |||
| 57 | + | ||||
| 58 | + | head := func(t *testing.T) string { | |||
| 59 | + | t.Helper() | |||
| 60 | + | res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{}) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | return res.CommitID.String() | |||
| 63 | + | } | |||
| 64 | + | rev := func(s string) domain.Revision { | |||
| 65 | + | r, err := domain.ParseRevision(s) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | return r | |||
| 68 | + | } | |||
| 69 | + | cli := func(t *testing.T) string { | |||
| 70 | + | t.Helper() | |||
| 71 | + | res, err := g.ResolveRevision(ctx, f.Name, domain.Revision{}) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | return res.CommitID.String() | |||
| 74 | + | } | |||
| 75 | + | require.Equal(t, work.Git("rev-parse", "HEAD"), head(t), "the first read") | |||
| 76 | + | ||||
| 77 | + | for i, step := range []struct { | |||
| 78 | + | name string | |||
| 79 | + | loose bool | |||
| 80 | + | after func() | |||
| 81 | + | }{ | |||
| 82 | + | {"pack", false, nil}, | |||
| 83 | + | {"loose objects", true, nil}, | |||
| 84 | + | {"repack replaces the packs", false, func() { f.Git("repack", "-adf") }}, | |||
| 85 | + | {"commit-graph layer", false, func() { f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split") }}, | |||
| 86 | + | {"gc packs the refs", false, func() { f.Git("gc", "--quiet", "--prune=now") }}, | |||
| 87 | + | } { | |||
| 88 | + | t.Run(step.name, func(t *testing.T) { | |||
| 89 | + | work.Write("a.txt", strings.Repeat("x", i+2)+"\n").Write("dir/new.txt", step.name+"\n") | |||
| 90 | + | c := work.Commit("push " + step.name) | |||
| 91 | + | push(t, work, bare, "main", step.loose) | |||
| 92 | + | // Not a single Refresh in between: the very next read sees it. | |||
| 93 | + | assert.Equal(t, c, head(t)) | |||
| 94 | + | page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 3}) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.NotEmpty(t, page.Commits) | |||
| 97 | + | assert.Equal(t, c, page.Commits[0].ID.String()) | |||
| 98 | + | tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | require.NotNil(t, tree.Entries[0].LastCommit) | |||
| 101 | + | assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String(), "dir/ was last touched by the push") | |||
| 102 | + | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "dir/new.txt")) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | body, err := io.ReadAll(rc) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.NoError(t, rc.Close()) | |||
| 107 | + | assert.Equal(t, step.name+"\n", string(body)) | |||
| 108 | + | assert.Equal(t, cli(t), head(t), "the same answer as git") | |||
| 109 | + | ||||
| 110 | + | if step.after != nil { | |||
| 111 | + | step.after() | |||
| 112 | + | assert.Equal(t, c, head(t), "after maintenance") | |||
| 113 | + | tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String()) | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | t.Run("new and deleted refs", func(t *testing.T) { | |||
| 121 | + | work.Git("branch", "-f", "feature/new", "HEAD") | |||
| 122 | + | push(t, work, bare, "feature/new", false) | |||
| 123 | + | res, err := a.ResolveRevision(ctx, f.Name, rev("feature/new")) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | assert.Equal(t, "refs/heads/feature/new", res.Ref) | |||
| 126 | + | ||||
| 127 | + | work.Git("tag", "-a", "-m", "release", "v9") | |||
| 128 | + | work.Git("push", "--quiet", bare, "refs/tags/v9") | |||
| 129 | + | refs, err := a.ListRefs(ctx, f.Name) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | var names []string | |||
| 132 | + | for _, r := range refs { | |||
| 133 | + | names = append(names, r.Name) | |||
| 134 | + | } | |||
| 135 | + | assert.Contains(t, names, "refs/tags/v9") | |||
| 136 | + | want, err := g.ListRefs(ctx, f.Name) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | assert.Equal(t, want, refs) | |||
| 139 | + | ||||
| 140 | + | f.Git("update-ref", "-d", "refs/heads/feature/new") | |||
| 141 | + | _, err = a.ResolveRevision(ctx, f.Name, rev("feature/new")) | |||
| 142 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 143 | + | f.Git("pack-refs", "--all") | |||
| 144 | + | f.Git("update-ref", "-d", "refs/tags/v9") | |||
| 145 | + | _, err = a.ResolveRevision(ctx, f.Name, rev("v9")) | |||
| 146 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 147 | + | }) | |||
| 148 | + | ||||
| 149 | + | t.Run("the repository is deleted and recreated", func(t *testing.T) { | |||
| 150 | + | require.NoError(t, g.Trash(ctx, f.Name)) | |||
| 151 | + | _, err := a.ListRefs(ctx, f.Name) | |||
| 152 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 153 | + | assert.Equal(t, 0, a.OpenRepos(), "the trash hook dropped the repository") | |||
| 154 | + | ||||
| 155 | + | again := readertest.NewFixture(t, data, "pushed") | |||
| 156 | + | again.Write("b.txt", "new repo\n") | |||
| 157 | + | c := again.Commit("a new repository under the old name") | |||
| 158 | + | assert.Equal(t, c, head(t), "no trace of the old repository") | |||
| 159 | + | }) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // TestReplacedDirectoryIsNotConfused: the cache is keyed by directory, so a | |||
| 163 | + | // directory replaced by another repository (a restore, a rename onto the same | |||
| 164 | + | // name) must not serve the old one: the inode tells. | |||
| 165 | + | func TestReplacedDirectoryIsNotConfused(t *testing.T) { | |||
| 166 | + | ctx := context.Background() | |||
| 167 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour }) | |||
| 168 | + | one := readertest.NewFixture(t, data, "one") | |||
| 169 | + | one.Write("a", "1\n") | |||
| 170 | + | c1 := one.Commit("one") | |||
| 171 | + | res, err := a.ResolveRevision(ctx, one.Name, domain.Revision{}) | |||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | require.Equal(t, c1, res.CommitID.String()) | |||
| 174 | + | ||||
| 175 | + | two := readertest.NewFixture(t, t.TempDir(), "two") | |||
| 176 | + | two.Write("b", "2\n") | |||
| 177 | + | c2 := two.Commit("two") | |||
| 178 | + | require.NoError(t, os.RemoveAll(one.Bare)) | |||
| 179 | + | require.NoError(t, os.Rename(two.Bare, one.Bare)) | |||
| 180 | + | res, err = a.ResolveRevision(ctx, one.Name, domain.Revision{}) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | assert.Equal(t, c2, res.CommitID.String()) | |||
| 183 | + | } | |||
A internal/adapters/ermitegit/helpers_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/adapters/ermitegit" | |||
| 16 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // newAdapter prepares a data directory and an ermitegit adapter around a | |||
| 21 | + | // gitcli reader, with the given routes. Without git it skips locally and | |||
| 22 | + | // fails when CI is set. Both are closed when the test ends. | |||
| 23 | + | func newAdapter(t testing.TB, routes ermitegit.Routes, tune func(*ermitegit.Config)) (*ermitegit.Adapter, *gitcli.Git, string) { | |||
| 24 | + | t.Helper() | |||
| 25 | + | bin, err := exec.LookPath("git") | |||
| 26 | + | if err != nil { | |||
| 27 | + | unusable(t, "git not found") | |||
| 28 | + | } | |||
| 29 | + | data := t.TempDir() | |||
| 30 | + | for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} { | |||
| 31 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700)) | |||
| 32 | + | } | |||
| 33 | + | g := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")}) | |||
| 34 | + | if _, err := g.CheckVersion(context.Background()); err != nil { | |||
| 35 | + | unusable(t, "git is not usable by ermite: %v", err) | |||
| 36 | + | } | |||
| 37 | + | cfg := ermitegit.Config{ | |||
| 38 | + | Fallback: g, | |||
| 39 | + | RepoDir: func(n domain.RepoName) string { return gitcli.RepoDir(data, n) }, | |||
| 40 | + | Routes: &routes, | |||
| 41 | + | } | |||
| 42 | + | if tune != nil { | |||
| 43 | + | tune(&cfg) | |||
| 44 | + | } | |||
| 45 | + | a := ermitegit.New(cfg) | |||
| 46 | + | g.OnTrash(a.Forget) | |||
| 47 | + | t.Cleanup(func() { | |||
| 48 | + | a.Close() | |||
| 49 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 50 | + | defer cancel() | |||
| 51 | + | require.NoError(t, g.Close(ctx)) | |||
| 52 | + | }) | |||
| 53 | + | return a, g, data | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // unusable skips the test, or fails it in CI so that CI cannot pass vacuously. | |||
| 57 | + | func unusable(t testing.TB, format string, args ...any) { | |||
| 58 | + | t.Helper() | |||
| 59 | + | if os.Getenv("CI") != "" { | |||
| 60 | + | t.Fatalf(format, args...) | |||
| 61 | + | } | |||
| 62 | + | t.Skipf(format, args...) | |||
| 63 | + | } | |||
A internal/adapters/ermitegit/routes.go +56 -0
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ermitegit | |||
| 4 | + | ||||
| 5 | + | // DefaultRoutes returns the routes of production. | |||
| 6 | + | // | |||
| 7 | + | // A method is routed to ermite-git only when it passes the shared conformance | |||
| 8 | + | // table (TestConformance, TestCompareWithCLI) and its benchmark beats the CLI | |||
| 9 | + | // adapter in time AND in allocations, on the generated fixture and on lazygit | |||
| 10 | + | // (BenchmarkRepoReader, ERMITE_GIT_BENCH_REPO). A method that does not stays | |||
| 11 | + | // on the CLI, with the numbers that decided it. GetCommit is not routable: the | |||
| 12 | + | // diff stays on git. | |||
| 13 | + | // | |||
| 14 | + | // The numbers are the median of three runs of `go test -bench RepoReader | |||
| 15 | + | // -benchmem` on an AMD Ryzen 9 7900X3D, git 2.56, ermite-git ea9bcf1, as | |||
| 16 | + | // "time / allocations per call", CLI against ermite-git. The fixture has 220 | |||
| 17 | + | // commits, 8 directories of 40 files and a 10 MiB blob; lazygit has 7,550 | |||
| 18 | + | // commits. Only the allocations of the Go process are counted, which favours | |||
| 19 | + | // the CLI (its work happens in git processes). | |||
| 20 | + | // | |||
| 21 | + | // fixture lazygit | |||
| 22 | + | // ListRefs 900 µs/126 → 29 µs/89 900 µs/89 → 20 µs/43 win | |||
| 23 | + | // ResolveRevision branch 24 µs/19 → 5 µs/14 26 µs/19 → 5 µs/14 win | |||
| 24 | + | // ResolveRevision full id 10 µs/22 → 2 µs/6 9 µs/22 → 2 µs/6 win | |||
| 25 | + | // ResolveRevision abbrev 65 µs/31 → 12 µs/33 65 µs/31 → 13 µs/33 allocs +2 | |||
| 26 | + | // ResolveRefPath 108 µs/59 → 20 µs/72 159 µs/79 → 28 µs/101 allocs +13 | |||
| 27 | + | // GetTree dir 736 µs/141 → 26 µs/51 1.3 ms/1059 → 3.5 ms/2634 loses on lazygit | |||
| 28 | + | // GetTree root 717 µs/114 → 14 µs/36 812 µs/167 → 50 µs/78 win | |||
| 29 | + | // GetTree root+last 4.0 ms/1022 → 73 µs/139 20 ms/3084 → 6.2 ms/4470 allocs +45% | |||
| 30 | + | // GetTree dir+last 5.3 ms/1452 → 176 µs/247 196 ms/17143 → 11 ms/8058 win | |||
| 31 | + | // OpenBlob small 46 µs/25 → 17 µs/28 53 µs/25 → 29 µs/57 allocs +2.3x | |||
| 32 | + | // OpenBlob 10 MiB 21 ms/64 → 49 ms/28 3.4 ms/64 → 5.6 ms/140 loses (inflate speed) | |||
| 33 | + | // ListCommits page 1 1.0 ms/404 → 59 µs/149 1.3 ms/415 → 368 µs/265 win | |||
| 34 | + | // ListCommits skip 5000 1.0 ms/404 → 74 µs/149 2.0 ms/404 → 433 µs/153 win | |||
| 35 | + | // ListCommits -- file 1.6 ms/138 → 453 µs/355 12.7 ms/117 → 6.1 ms/6870 allocs +58x | |||
| 36 | + | // ListCommits -- dir 1.3 ms/403 → 202 µs/239 6.6 ms/403 → 2.0 ms/1988 allocs +5x | |||
| 37 | + | // | |||
| 38 | + | // Where a method has several shapes, every shape must win to be routed: the | |||
| 39 | + | // constants GetTree, GetTreeLastCommits and ListCommitsPath cover directories | |||
| 40 | + | // of any size and files of any history. The losses in allocations come from | |||
| 41 | + | // the inflate of tree and commit objects (compress/zlib allocates an adler32 | |||
| 42 | + | // digest and the flate tables on every Reset): once the library avoids that, | |||
| 43 | + | // re-run the benchmark and flip the constants it wins. | |||
| 44 | + | func DefaultRoutes() Routes { | |||
| 45 | + | return Routes{ | |||
| 46 | + | ListRefs: ErmiteGit, | |||
| 47 | + | ResolveRevision: ErmiteGit, | |||
| 48 | + | ResolveRevisionAbbrev: CLI, | |||
| 49 | + | ResolveRefPath: CLI, | |||
| 50 | + | GetTree: CLI, | |||
| 51 | + | GetTreeLastCommits: CLI, | |||
| 52 | + | OpenBlob: CLI, | |||
| 53 | + | ListCommits: ErmiteGit, | |||
| 54 | + | ListCommitsPath: CLI, | |||
| 55 | + | } | |||
| 56 | + | } | |||
A internal/adapters/gitcli/conformance_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/ports" | |||
| 9 | + | "bnema.dev/ermite/internal/readertest" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestConformance runs the shared RepoReader table against the CLI adapter, | |||
| 13 | + | // the oracle of the others. | |||
| 14 | + | func TestConformance(t *testing.T) { | |||
| 15 | + | readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) { | |||
| 16 | + | g, data := newReader(t, nil) | |||
| 17 | + | return g, data | |||
| 18 | + | }) | |||
| 19 | + | } | |||
M internal/adapters/gitcli/diff_test.go +75 -74
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | "bnema.dev/ermite/internal/domain" | 15 | "bnema.dev/ermite/internal/domain" | ||
| 16 | + | "bnema.dev/ermite/internal/readertest" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | // unified renders hunks the way git prints them after the "+++" line. | 19 | // unified renders hunks the way git prints them after the "+++" line. | ||
| @@ -58,16 +59,16 @@ func gitSections(out string) []string { | |||||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | // showSections is git's own patch text of a commit against its first parent. | 61 | // showSections is git's own patch text of a commit against its first parent. | ||
| 61 | - | func (f *fixtureRepo) showSections(id string) []string { | 62 | + | func showSections(f *readertest.Fixture, id string) []string { |
| 62 | - | f.t.Helper() | 63 | + | f.T.Helper() |
| 63 | - | out := f.git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id) | 64 | + | out := f.Git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id) |
| 64 | return gitSections(strings.ToValidUTF8(out, "\uFFFD")) | 65 | return gitSections(strings.ToValidUTF8(out, "\uFFFD")) | ||
| 65 | } | 66 | } | ||
| 66 | 67 | ||||
| 67 | // showNumstat is git's own numstat of a commit against its first parent. | 68 | // showNumstat is git's own numstat of a commit against its first parent. | ||
| 68 | - | func (f *fixtureRepo) showNumstat(id string) []string { | 69 | + | func showNumstat(f *readertest.Fixture, id string) []string { |
| 69 | - | f.t.Helper() | 70 | + | f.T.Helper() |
| 70 | - | out := f.git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id) | 71 | + | out := f.Git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id) |
| 71 | if out == "" { | 72 | if out == "" { | ||
| 72 | return nil | 73 | return nil | ||
| 73 | } | 74 | } | ||
| @@ -76,11 +77,11 @@ func (f *fixtureRepo) showNumstat(id string) []string { | |||||
| 76 | 77 | ||||
| 77 | // assertMatchesGitShow checks the structured diff of a commit against the | 78 | // assertMatchesGitShow checks the structured diff of a commit against the | ||
| 78 | // text of `git show`: the patch of every file, its counts, and the totals. | 79 | // text of `git show`: the patch of every file, its counts, and the totals. | ||
| 79 | - | func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.CommitDetail) { | 80 | + | func assertMatchesGitShow(t *testing.T, f *readertest.Fixture, id string, d domain.CommitDetail) { |
| 80 | t.Helper() | 81 | t.Helper() | ||
| 81 | - | sections := f.showSections(id) | 82 | + | sections := showSections(f, id) |
| 82 | var adds, dels int | 83 | var adds, dels int | ||
| 83 | - | numstat := f.showNumstat(id) | 84 | + | numstat := showNumstat(f, id) |
| 84 | require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file") | 85 | require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file") | ||
| 85 | si := 0 | 86 | si := 0 | ||
| 86 | for i, file := range d.Diff.Files { | 87 | for i, file := range d.Diff.Files { | ||
| @@ -113,21 +114,21 @@ func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.Comm | |||||
| 113 | func TestGetCommitMatchesGitShow(t *testing.T) { | 114 | func TestGetCommitMatchesGitShow(t *testing.T) { | ||
| 114 | ctx := context.Background() | 115 | ctx := context.Background() | ||
| 115 | g, data := newReader(t, nil) | 116 | g, data := newReader(t, nil) | ||
| 116 | - | h := newHistory(t, data) | 117 | + | h := readertest.NewHistory(t, data) |
| 117 | 118 | ||||
| 118 | for _, tt := range []struct{ name, id string }{ | 119 | for _, tt := range []struct{ name, id string }{ | ||
| 119 | - | {"root commit: adds, binary, empty file", h.c1}, | 120 | + | {"root commit: adds, binary, empty file", h.C1}, |
| 120 | - | {"rename with change, binary, mode change, add, delete", h.c2}, | 121 | + | {"rename with change, binary, mode change, add, delete", h.C2}, |
| 121 | - | {"type change", h.c3}, | 122 | + | {"type change", h.C3}, |
| 122 | - | {"merge, against the first parent", h.merge}, | 123 | + | {"merge, against the first parent", h.Merge}, |
| 123 | - | {"side branch", h.side}, | 124 | + | {"side branch", h.Side}, |
| 124 | } { | 125 | } { | ||
| 125 | t.Run(tt.name, func(t *testing.T) { | 126 | t.Run(tt.name, func(t *testing.T) { | ||
| 126 | - | d, err := g.GetCommit(ctx, h.name, domain.RevisionOf(mustID(t, tt.id)), domain.DefaultDiffLimits()) | 127 | + | d, err := g.GetCommit(ctx, h.Name, domain.RevisionOf(readertest.MustID(t, tt.id)), domain.DefaultDiffLimits()) |
| 127 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
| 128 | assert.Equal(t, tt.id, d.Commit.ID.String()) | 129 | assert.Equal(t, tt.id, d.Commit.ID.String()) | ||
| 129 | assert.False(t, d.Diff.Truncated) | 130 | assert.False(t, d.Diff.Truncated) | ||
| 130 | - | assertMatchesGitShow(t, h.fixtureRepo, tt.id, d) | 131 | + | assertMatchesGitShow(t, h.Fixture, tt.id, d) |
| 131 | }) | 132 | }) | ||
| 132 | } | 133 | } | ||
| 133 | } | 134 | } | ||
| @@ -135,7 +136,7 @@ func TestGetCommitMatchesGitShow(t *testing.T) { | |||||
| 135 | func TestGetCommitFileMetadata(t *testing.T) { | 136 | func TestGetCommitFileMetadata(t *testing.T) { | ||
| 136 | ctx := context.Background() | 137 | ctx := context.Background() | ||
| 137 | g, data := newReader(t, nil) | 138 | g, data := newReader(t, nil) | ||
| 138 | - | h := newHistory(t, data) | 139 | + | h := readertest.NewHistory(t, data) |
| 139 | 140 | ||||
| 140 | byPath := func(d domain.Diff) map[string]domain.FileDiff { | 141 | byPath := func(d domain.Diff) map[string]domain.FileDiff { | ||
| 141 | m := map[string]domain.FileDiff{} | 142 | m := map[string]domain.FileDiff{} | ||
| @@ -144,11 +145,11 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 144 | } | 145 | } | ||
| 145 | return m | 146 | return m | ||
| 146 | } | 147 | } | ||
| 147 | - | id := func(spec string) domain.ObjectID { return mustID(t, h.git("rev-parse", spec)) } | 148 | + | id := func(spec string) domain.ObjectID { return readertest.MustID(t, h.Git("rev-parse", spec)) } |
| 148 | 149 | ||||
| 149 | - | d, err := g.GetCommit(ctx, h.name, rev(t, "v2"), domain.DefaultDiffLimits()) | 150 | + | d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "v2"), domain.DefaultDiffLimits()) |
| 150 | require.NoError(t, err) | 151 | require.NoError(t, err) | ||
| 151 | - | assert.Equal(t, h.c2, d.Commit.ID.String()) | 152 | + | assert.Equal(t, h.C2, d.Commit.ID.String()) |
| 152 | files := byPath(d.Diff) | 153 | files := byPath(d.Diff) | ||
| 153 | require.Len(t, files, 5) | 154 | require.Len(t, files, 5) | ||
| 154 | 155 | ||||
| @@ -159,8 +160,8 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 159 | assert.Equal(t, 0, ren.Deletions) | 160 | assert.Equal(t, 0, ren.Deletions) | ||
| 160 | assert.Equal(t, uint32(0o100644), ren.OldMode) | 161 | assert.Equal(t, uint32(0o100644), ren.OldMode) | ||
| 161 | assert.Equal(t, uint32(0o100644), ren.NewMode) | 162 | assert.Equal(t, uint32(0o100644), ren.NewMode) | ||
| 162 | - | assert.Equal(t, id(h.c1+":a.txt"), ren.OldID) | 163 | + | assert.Equal(t, id(h.C1+":a.txt"), ren.OldID) |
| 163 | - | assert.Equal(t, id(h.c2+":b.txt"), ren.NewID) | 164 | + | assert.Equal(t, id(h.C2+":b.txt"), ren.NewID) |
| 164 | require.Len(t, ren.Hunks, 1) | 165 | require.Len(t, ren.Hunks, 1) | ||
| 165 | assert.Equal(t, domain.Hunk{ | 166 | assert.Equal(t, domain.Hunk{ | ||
| 166 | OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4, | 167 | OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4, | ||
| @@ -203,7 +204,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 203 | last := add.Hunks[0].Lines[1] | 204 | last := add.Hunks[0].Lines[1] | ||
| 204 | assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last) | 205 | assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last) | ||
| 205 | 206 | ||||
| 206 | - | d, err = g.GetCommit(ctx, h.name, rev(t, h.c3), domain.DefaultDiffLimits()) | 207 | + | d, err = g.GetCommit(ctx, h.Name, readertest.Rev(t, h.C3), domain.DefaultDiffLimits()) |
| 207 | require.NoError(t, err) | 208 | require.NoError(t, err) | ||
| 208 | require.Len(t, d.Diff.Files, 1) | 209 | require.Len(t, d.Diff.Files, 1) | ||
| 209 | tc := d.Diff.Files[0] | 210 | tc := d.Diff.Files[0] | ||
| @@ -216,9 +217,9 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 216 | func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) { | 217 | func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) { | ||
| 217 | ctx := context.Background() | 218 | ctx := context.Background() | ||
| 218 | g, data := newReader(t, nil) | 219 | g, data := newReader(t, nil) | ||
| 219 | - | h := newHistory(t, data) | 220 | + | h := readertest.NewHistory(t, data) |
| 220 | 221 | ||||
| 221 | - | d, err := g.GetCommit(ctx, h.name, rev(t, "main"), domain.DefaultDiffLimits()) | 222 | + | d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "main"), domain.DefaultDiffLimits()) |
| 222 | require.NoError(t, err) | 223 | require.NoError(t, err) | ||
| 223 | require.Len(t, d.Commit.Parents, 2) | 224 | require.Len(t, d.Commit.Parents, 2) | ||
| 224 | require.Len(t, d.Diff.Files, 1) | 225 | require.Len(t, d.Diff.Files, 1) | ||
| @@ -229,10 +230,10 @@ func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) { | |||||
| 229 | func TestGetCommitEmptyCommit(t *testing.T) { | 230 | func TestGetCommitEmptyCommit(t *testing.T) { | ||
| 230 | ctx := context.Background() | 231 | ctx := context.Background() | ||
| 231 | g, data := newReader(t, nil) | 232 | g, data := newReader(t, nil) | ||
| 232 | - | f := newFixtureRepo(t, data, "empty-commit") | 233 | + | f := readertest.NewFixture(t, data, "empty-commit") |
| 233 | - | id := f.commit("nothing") | 234 | + | id := f.Commit("nothing") |
| 234 | 235 | ||||
| 235 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits()) | 236 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) |
| 236 | require.NoError(t, err) | 237 | require.NoError(t, err) | ||
| 237 | assert.Equal(t, id, d.Commit.ID.String()) | 238 | assert.Equal(t, id, d.Commit.ID.String()) | ||
| 238 | assert.Empty(t, d.Diff.Files) | 239 | assert.Empty(t, d.Diff.Files) | ||
| @@ -243,9 +244,9 @@ func TestGetCommitEmptyCommit(t *testing.T) { | |||||
| 243 | func TestGetCommitErrors(t *testing.T) { | 244 | func TestGetCommitErrors(t *testing.T) { | ||
| 244 | ctx := context.Background() | 245 | ctx := context.Background() | ||
| 245 | g, data := newReader(t, nil) | 246 | g, data := newReader(t, nil) | ||
| 246 | - | h := newHistory(t, data) | 247 | + | h := readertest.NewHistory(t, data) |
| 247 | 248 | ||||
| 248 | - | _, err := g.GetCommit(ctx, h.name, rev(t, "nope"), domain.DefaultDiffLimits()) | 249 | + | _, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "nope"), domain.DefaultDiffLimits()) |
| 249 | assert.ErrorIs(t, err, domain.ErrNotFound) | 250 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 250 | _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits()) | 251 | _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits()) | ||
| 251 | assert.ErrorIs(t, err, domain.ErrNotFound) | 252 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| @@ -254,19 +255,19 @@ func TestGetCommitErrors(t *testing.T) { | |||||
| 254 | func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) { | 255 | func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) { | ||
| 255 | ctx := context.Background() | 256 | ctx := context.Background() | ||
| 256 | g, data := newReader(t, nil) | 257 | g, data := newReader(t, nil) | ||
| 257 | - | f := newFixtureRepo(t, data, "hunks") | 258 | + | f := readertest.NewFixture(t, data, "hunks") |
| 258 | var src strings.Builder | 259 | var src strings.Builder | ||
| 259 | src.WriteString("package x\n\nfunc Alpha() {\n") | 260 | src.WriteString("package x\n\nfunc Alpha() {\n") | ||
| 260 | for i := range 40 { | 261 | for i := range 40 { | ||
| 261 | fmt.Fprintf(&src, "\tline(%d)\n", i) | 262 | fmt.Fprintf(&src, "\tline(%d)\n", i) | ||
| 262 | } | 263 | } | ||
| 263 | src.WriteString("}\n") | 264 | src.WriteString("}\n") | ||
| 264 | - | f.write("x.go", src.String()).write("latin", "caf\xe9\nok\n") | 265 | + | f.Write("x.go", src.String()).Write("latin", "caf\xe9\nok\n") |
| 265 | - | f.commit("v1") | 266 | + | f.Commit("v1") |
| 266 | - | f.write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).write("latin", "caf\xe9\nOK\n") | 267 | + | f.Write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).Write("latin", "caf\xe9\nOK\n") |
| 267 | - | id := f.commit("v2") | 268 | + | id := f.Commit("v2") |
| 268 | 269 | ||||
| 269 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits()) | 270 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) |
| 270 | require.NoError(t, err) | 271 | require.NoError(t, err) | ||
| 271 | assertMatchesGitShow(t, f, id, d) | 272 | assertMatchesGitShow(t, f, id, d) | ||
| 272 | var gofile, latin domain.FileDiff | 273 | var gofile, latin domain.FileDiff | ||
| @@ -286,11 +287,11 @@ func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) { | |||||
| 286 | } | 287 | } | ||
| 287 | 288 | ||||
| 288 | // manyLines commits files and returns the id. | 289 | // manyLines commits files and returns the id. | ||
| 289 | - | func commitFiles(f *fixtureRepo, msg string, files map[string]int) string { | 290 | + | func commitFiles(f *readertest.Fixture, msg string, files map[string]int) string { |
| 290 | for p, n := range files { | 291 | for p, n := range files { | ||
| 291 | - | f.lines(p, strings.ReplaceAll(p, "/", "_"), n) | 292 | + | f.Lines(p, strings.ReplaceAll(p, "/", "_"), n) |
| 292 | } | 293 | } | ||
| 293 | - | return f.commit(msg) | 294 | + | return f.Commit(msg) |
| 294 | } | 295 | } | ||
| 295 | 296 | ||||
| 296 | func TestDiffLimits(t *testing.T) { | 297 | func TestDiffLimits(t *testing.T) { | ||
| @@ -308,9 +309,9 @@ func TestDiffLimits(t *testing.T) { | |||||
| 308 | } | 309 | } | ||
| 309 | 310 | ||||
| 310 | t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) { | 311 | t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) { | ||
| 311 | - | f := newFixtureRepo(t, data, "limit-file") | 312 | + | f := readertest.NewFixture(t, data, "limit-file") |
| 312 | commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3}) | 313 | commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3}) | ||
| 313 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 314 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 314 | require.NoError(t, err) | 315 | require.NoError(t, err) | ||
| 315 | exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small") | 316 | exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small") | ||
| 316 | 317 | ||||
| @@ -327,8 +328,8 @@ func TestDiffLimits(t *testing.T) { | |||||
| 327 | }) | 328 | }) | ||
| 328 | 329 | ||||
| 329 | t.Run("context counts towards the file limit", func(t *testing.T) { | 330 | t.Run("context counts towards the file limit", func(t *testing.T) { | ||
| 330 | - | f := newFixtureRepo(t, data, "limit-context") | 331 | + | f := readertest.NewFixture(t, data, "limit-context") |
| 331 | - | f.lines("big", "l", 3000).commit("v1") | 332 | + | f.Lines("big", "l", 3000).Commit("v1") |
| 332 | // Every 7th line changes: ~428 changed lines, but the hunks merge | 333 | // Every 7th line changes: ~428 changed lines, but the hunks merge | ||
| 333 | // into one that spans the file with its context. | 334 | // into one that spans the file with its context. | ||
| 334 | var b strings.Builder | 335 | var b strings.Builder | ||
| @@ -339,10 +340,10 @@ func TestDiffLimits(t *testing.T) { | |||||
| 339 | fmt.Fprintf(&b, "l %d\n", i) | 340 | fmt.Fprintf(&b, "l %d\n", i) | ||
| 340 | } | 341 | } | ||
| 341 | } | 342 | } | ||
| 342 | - | f.write("big", b.String()) | 343 | + | f.Write("big", b.String()) |
| 343 | - | f.write("tiny", "x\n") | 344 | + | f.Write("tiny", "x\n") |
| 344 | - | id := f.commit("v2") | 345 | + | id := f.Commit("v2") |
| 345 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 346 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 346 | require.NoError(t, err) | 347 | require.NoError(t, err) | ||
| 347 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | 348 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | ||
| 348 | assert.True(t, big.Truncated) | 349 | assert.True(t, big.Truncated) | ||
| @@ -355,7 +356,7 @@ func TestDiffLimits(t *testing.T) { | |||||
| 355 | }) | 356 | }) | ||
| 356 | 357 | ||||
| 357 | t.Run("the total limit truncates the file that does not fit", func(t *testing.T) { | 358 | t.Run("the total limit truncates the file that does not fit", func(t *testing.T) { | ||
| 358 | - | f := newFixtureRepo(t, data, "limit-total") | 359 | + | f := readertest.NewFixture(t, data, "limit-total") |
| 359 | files := map[string]int{} | 360 | files := map[string]int{} | ||
| 360 | for i := range 11 { | 361 | for i := range 11 { | ||
| 361 | files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000 | 362 | files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000 | ||
| @@ -364,7 +365,7 @@ func TestDiffLimits(t *testing.T) { | |||||
| 364 | files["h-big"] = 1500 // does not fit | 365 | files["h-big"] = 1500 // does not fit | ||
| 365 | files["i-small"] = 10 // fits | 366 | files["i-small"] = 10 // fits | ||
| 366 | commitFiles(f, "total", files) | 367 | commitFiles(f, "total", files) | ||
| 367 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 368 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 368 | require.NoError(t, err) | 369 | require.NoError(t, err) | ||
| 369 | require.Len(t, d.Diff.Files, 14) | 370 | require.Len(t, d.Diff.Files, 14) | ||
| 370 | total := 0 | 371 | total := 0 | ||
| @@ -387,12 +388,12 @@ func TestDiffLimits(t *testing.T) { | |||||
| 387 | }) | 388 | }) | ||
| 388 | 389 | ||||
| 389 | t.Run("files past the file limit are omitted but counted", func(t *testing.T) { | 390 | t.Run("files past the file limit are omitted but counted", func(t *testing.T) { | ||
| 390 | - | f := newFixtureRepo(t, data, "limit-files") | 391 | + | f := readertest.NewFixture(t, data, "limit-files") |
| 391 | for i := range 305 { | 392 | for i := range 305 { | ||
| 392 | - | f.write(fmt.Sprintf("many/f%03d", i), "x\n") | 393 | + | f.Write(fmt.Sprintf("many/f%03d", i), "x\n") |
| 393 | } | 394 | } | ||
| 394 | - | f.commit("many") | 395 | + | f.Commit("many") |
| 395 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 396 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 396 | require.NoError(t, err) | 397 | require.NoError(t, err) | ||
| 397 | assert.Len(t, d.Diff.Files, 300) | 398 | assert.Len(t, d.Diff.Files, 300) | ||
| 398 | assert.True(t, d.Diff.Truncated) | 399 | assert.True(t, d.Diff.Truncated) | ||
| @@ -406,28 +407,28 @@ func TestDiffLimits(t *testing.T) { | |||||
| 406 | }) | 407 | }) | ||
| 407 | 408 | ||||
| 408 | t.Run("exactly 300 files is not truncated", func(t *testing.T) { | 409 | t.Run("exactly 300 files is not truncated", func(t *testing.T) { | ||
| 409 | - | f := newFixtureRepo(t, data, "limit-300") | 410 | + | f := readertest.NewFixture(t, data, "limit-300") |
| 410 | for i := range 300 { | 411 | for i := range 300 { | ||
| 411 | - | f.write(fmt.Sprintf("f%03d", i), "x\n") | 412 | + | f.Write(fmt.Sprintf("f%03d", i), "x\n") |
| 412 | } | 413 | } | ||
| 413 | - | f.commit("300") | 414 | + | f.Commit("300") |
| 414 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 415 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 415 | require.NoError(t, err) | 416 | require.NoError(t, err) | ||
| 416 | assert.Len(t, d.Diff.Files, 300) | 417 | assert.Len(t, d.Diff.Files, 300) | ||
| 417 | assert.False(t, d.Diff.Truncated) | 418 | assert.False(t, d.Diff.Truncated) | ||
| 418 | }) | 419 | }) | ||
| 419 | 420 | ||||
| 420 | t.Run("custom limits and the byte limit", func(t *testing.T) { | 421 | t.Run("custom limits and the byte limit", func(t *testing.T) { | ||
| 421 | - | f := newFixtureRepo(t, data, "limit-custom") | 422 | + | f := readertest.NewFixture(t, data, "limit-custom") |
| 422 | commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10}) | 423 | commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10}) | ||
| 423 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20}) | 424 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20}) |
| 424 | require.NoError(t, err) | 425 | require.NoError(t, err) | ||
| 425 | require.Len(t, d.Diff.Files, 2) | 426 | require.Len(t, d.Diff.Files, 2) | ||
| 426 | assert.False(t, d.Diff.Files[0].Truncated) | 427 | assert.False(t, d.Diff.Files[0].Truncated) | ||
| 427 | assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left") | 428 | assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left") | ||
| 428 | assert.Equal(t, 3, d.Stats.FilesChanged) | 429 | assert.Equal(t, 3, d.Stats.FilesChanged) | ||
| 429 | 430 | ||||
| 430 | - | d, err = g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50}) | 431 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50}) |
| 431 | require.NoError(t, err) | 432 | require.NoError(t, err) | ||
| 432 | require.Len(t, d.Diff.Files, 3) | 433 | require.Len(t, d.Diff.Files, 3) | ||
| 433 | assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50") | 434 | assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50") | ||
| @@ -438,9 +439,9 @@ func TestDiffLimits(t *testing.T) { | |||||
| 438 | }) | 439 | }) | ||
| 439 | 440 | ||||
| 440 | t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) { | 441 | t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) { | ||
| 441 | - | f := newFixtureRepo(t, data, "limit-line") | 442 | + | f := readertest.NewFixture(t, data, "limit-line") |
| 442 | - | f.write("long", strings.Repeat("x", 200<<10)+"\n").write("short", "ok\n").commit("long") | 443 | + | f.Write("long", strings.Repeat("x", 200<<10)+"\n").Write("short", "ok\n").Commit("long") |
| 443 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 444 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 444 | require.NoError(t, err) | 445 | require.NoError(t, err) | ||
| 445 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | 446 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | ||
| 446 | assert.True(t, long.Truncated) | 447 | assert.True(t, long.Truncated) | ||
| @@ -450,12 +451,12 @@ func TestDiffLimits(t *testing.T) { | |||||
| 450 | }) | 451 | }) | ||
| 451 | 452 | ||||
| 452 | t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) { | 453 | t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) { | ||
| 453 | - | f := newFixtureRepo(t, data, "limit-mixed") | 454 | + | f := readertest.NewFixture(t, data, "limit-mixed") |
| 454 | - | f.lines("a-big", "x", 2500).writeBytes("b-bin", []byte{0, 1, 2}).write("c-mode", "m\n").write("d-text", "t\n") | 455 | + | f.Lines("a-big", "x", 2500).WriteBytes("b-bin", []byte{0, 1, 2}).Write("c-mode", "m\n").Write("d-text", "t\n") |
| 455 | - | f.commit("v1") | 456 | + | f.Commit("v1") |
| 456 | - | f.lines("a-big", "y", 2500).writeBytes("b-bin", []byte{0, 3}).chmod("c-mode", 0o755).write("d-text", "t2\n") | 457 | + | f.Lines("a-big", "y", 2500).WriteBytes("b-bin", []byte{0, 3}).Chmod("c-mode", 0o755).Write("d-text", "t2\n") |
| 457 | - | id := f.commit("v2") | 458 | + | id := f.Commit("v2") |
| 458 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | 459 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) |
| 459 | require.NoError(t, err) | 460 | require.NoError(t, err) | ||
| 460 | assert.True(t, find(d.Diff, "a-big").Truncated) | 461 | assert.True(t, find(d.Diff, "a-big").Truncated) | ||
| 461 | assert.True(t, find(d.Diff, "b-bin").Binary) | 462 | assert.True(t, find(d.Diff, "b-bin").Binary) | ||
| @@ -463,7 +464,7 @@ func TestDiffLimits(t *testing.T) { | |||||
| 463 | assert.False(t, find(d.Diff, "c-mode").Truncated) | 464 | assert.False(t, find(d.Diff, "c-mode").Truncated) | ||
| 464 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | 465 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | ||
| 465 | // What is kept matches git. | 466 | // What is kept matches git. | ||
| 466 | - | sections := f.showSections(id) | 467 | + | sections := showSections(f, id) |
| 467 | require.Len(t, sections, 4) | 468 | require.Len(t, sections, 4) | ||
| 468 | assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks)) | 469 | assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks)) | ||
| 469 | }) | 470 | }) | ||
| @@ -472,11 +473,11 @@ func TestDiffLimits(t *testing.T) { | |||||
| 472 | func TestGetCommitStopsGitEarly(t *testing.T) { | 473 | func TestGetCommitStopsGitEarly(t *testing.T) { | ||
| 473 | ctx := context.Background() | 474 | ctx := context.Background() | ||
| 474 | g, data := newReader(t, nil) | 475 | g, data := newReader(t, nil) | ||
| 475 | - | f := newFixtureRepo(t, data, "early") | 476 | + | f := readertest.NewFixture(t, data, "early") |
| 476 | - | f.lines("a", "x", 5).lines("z-huge", "y", 100000).commit("c") | 477 | + | f.Lines("a", "x", 5).Lines("z-huge", "y", 100000).Commit("c") |
| 477 | 478 | ||||
| 478 | // z-huge is truncated by the numstat alone, so nothing of it is read. | 479 | // z-huge is truncated by the numstat alone, so nothing of it is read. | ||
| 479 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits()) | 480 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) |
| 480 | require.NoError(t, err) | 481 | require.NoError(t, err) | ||
| 481 | require.Len(t, d.Diff.Files, 2) | 482 | require.Len(t, d.Diff.Files, 2) | ||
| 482 | assert.False(t, d.Diff.Files[0].Truncated) | 483 | assert.False(t, d.Diff.Files[0].Truncated) | ||
D internal/adapters/gitcli/fixture_test.go +0 -158
| @@ -1,158 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package gitcli_test | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "fmt" | |||
| 7 | - | "math/rand/v2" | |||
| 8 | - | "os" | |||
| 9 | - | "os/exec" | |||
| 10 | - | "path/filepath" | |||
| 11 | - | "strings" | |||
| 12 | - | "testing" | |||
| 13 | - | "time" | |||
| 14 | - | ||||
| 15 | - | "github.com/stretchr/testify/require" | |||
| 16 | - | ||||
| 17 | - | "bnema.dev/ermite/internal/domain" | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | // fixtureRepo builds a repository with real git: a bare repository in the | |||
| 21 | - | // data directory, and a work tree beside it that the helpers edit and commit. | |||
| 22 | - | type fixtureRepo struct { | |||
| 23 | - | t *testing.T | |||
| 24 | - | name domain.RepoName | |||
| 25 | - | bare string | |||
| 26 | - | work string | |||
| 27 | - | when time.Time | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // newFixtureRepo creates an empty bare repository repos/<name>.git whose HEAD | |||
| 31 | - | // is refs/heads/main. | |||
| 32 | - | func newFixtureRepo(t *testing.T, data, repo string) *fixtureRepo { | |||
| 33 | - | t.Helper() | |||
| 34 | - | f := &fixtureRepo{ | |||
| 35 | - | t: t, | |||
| 36 | - | name: name(t, repo), | |||
| 37 | - | bare: filepath.Join(data, "repos", repo+".git"), | |||
| 38 | - | work: t.TempDir(), | |||
| 39 | - | when: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), | |||
| 40 | - | } | |||
| 41 | - | out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.bare).CombinedOutput() | |||
| 42 | - | require.NoError(t, err, string(out)) | |||
| 43 | - | f.git("config", "core.compression", "0") | |||
| 44 | - | f.git("config", "core.autocrlf", "false") | |||
| 45 | - | return f | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // git runs git against the bare repository and the work tree. | |||
| 49 | - | func (f *fixtureRepo) git(args ...string) string { | |||
| 50 | - | f.t.Helper() | |||
| 51 | - | cmd := exec.Command("git", args...) | |||
| 52 | - | cmd.Dir = f.work | |||
| 53 | - | cmd.Env = []string{ | |||
| 54 | - | "PATH=" + os.Getenv("PATH"), | |||
| 55 | - | "HOME=" + f.work, | |||
| 56 | - | "GIT_CONFIG_NOSYSTEM=1", | |||
| 57 | - | "GIT_CONFIG_GLOBAL=/dev/null", | |||
| 58 | - | "GIT_DIR=" + f.bare, | |||
| 59 | - | "GIT_WORK_TREE=" + f.work, | |||
| 60 | - | "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org", | |||
| 61 | - | "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org", | |||
| 62 | - | "GIT_AUTHOR_DATE=" + f.when.Format(time.RFC3339), | |||
| 63 | - | "GIT_COMMITTER_DATE=" + f.when.Format(time.RFC3339), | |||
| 64 | - | } | |||
| 65 | - | out, err := cmd.CombinedOutput() | |||
| 66 | - | require.NoError(f.t, err, "git %s: %s", strings.Join(args, " "), out) | |||
| 67 | - | return strings.TrimSpace(string(out)) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func (f *fixtureRepo) path(p string) string { return filepath.Join(f.work, filepath.FromSlash(p)) } | |||
| 71 | - | ||||
| 72 | - | // write creates or replaces a file with the content. | |||
| 73 | - | func (f *fixtureRepo) write(p, content string) *fixtureRepo { | |||
| 74 | - | f.t.Helper() | |||
| 75 | - | require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755)) | |||
| 76 | - | require.NoError(f.t, os.WriteFile(f.path(p), []byte(content), 0o644)) | |||
| 77 | - | return f | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // writeBytes is write for binary content. | |||
| 81 | - | func (f *fixtureRepo) writeBytes(p string, content []byte) *fixtureRepo { | |||
| 82 | - | f.t.Helper() | |||
| 83 | - | require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755)) | |||
| 84 | - | require.NoError(f.t, os.WriteFile(f.path(p), content, 0o644)) | |||
| 85 | - | return f | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | // lines writes n lines "<prefix> <i>" to the file. | |||
| 89 | - | func (f *fixtureRepo) lines(p, prefix string, n int) *fixtureRepo { | |||
| 90 | - | var b strings.Builder | |||
| 91 | - | for i := range n { | |||
| 92 | - | fmt.Fprintf(&b, "%s %d\n", prefix, i) | |||
| 93 | - | } | |||
| 94 | - | return f.write(p, b.String()) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | func (f *fixtureRepo) remove(p string) *fixtureRepo { | |||
| 98 | - | f.t.Helper() | |||
| 99 | - | require.NoError(f.t, os.Remove(f.path(p))) | |||
| 100 | - | return f | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func (f *fixtureRepo) chmod(p string, mode os.FileMode) *fixtureRepo { | |||
| 104 | - | f.t.Helper() | |||
| 105 | - | require.NoError(f.t, os.Chmod(f.path(p), mode)) | |||
| 106 | - | return f | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func (f *fixtureRepo) symlink(target, p string) *fixtureRepo { | |||
| 110 | - | f.t.Helper() | |||
| 111 | - | require.NoError(f.t, os.Symlink(target, f.path(p))) | |||
| 112 | - | return f | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func (f *fixtureRepo) mv(from, to string) *fixtureRepo { | |||
| 116 | - | f.t.Helper() | |||
| 117 | - | require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(to)), 0o755)) | |||
| 118 | - | require.NoError(f.t, os.Rename(f.path(from), f.path(to))) | |||
| 119 | - | return f | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // commit stages everything and commits with the next timestamp; it returns | |||
| 123 | - | // the commit id. | |||
| 124 | - | func (f *fixtureRepo) commit(msg string) string { | |||
| 125 | - | f.t.Helper() | |||
| 126 | - | f.when = f.when.Add(time.Minute) | |||
| 127 | - | f.git("add", "-A") | |||
| 128 | - | f.git("commit", "--quiet", "--allow-empty", "-m", msg) | |||
| 129 | - | return f.git("rev-parse", "HEAD") | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // checkoutNew creates a branch from the current HEAD and switches to it. | |||
| 133 | - | func (f *fixtureRepo) checkoutNew(branch string) { | |||
| 134 | - | f.t.Helper() | |||
| 135 | - | f.git("checkout", "--quiet", "-b", branch) | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | func (f *fixtureRepo) checkout(branch string) { f.t.Helper(); f.git("checkout", "--quiet", branch) } | |||
| 139 | - | ||||
| 140 | - | // merge merges the branch with a merge commit. | |||
| 141 | - | func (f *fixtureRepo) merge(branch string) string { | |||
| 142 | - | f.t.Helper() | |||
| 143 | - | f.when = f.when.Add(time.Minute) | |||
| 144 | - | f.git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch) | |||
| 145 | - | return f.git("rev-parse", "HEAD") | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | // randomBytes returns n deterministic incompressible bytes. | |||
| 149 | - | func randomBytes(n int, seed uint64) []byte { | |||
| 150 | - | var s [32]byte | |||
| 151 | - | for i := range 8 { | |||
| 152 | - | s[i] = byte(seed >> (8 * i)) | |||
| 153 | - | } | |||
| 154 | - | r := rand.NewChaCha8(s) | |||
| 155 | - | b := make([]byte, n) | |||
| 156 | - | _, _ = r.Read(b) | |||
| 157 | - | return b | |||
| 158 | - | } | |||
M internal/adapters/gitcli/merge_test.go +51 -50
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | "bnema.dev/ermite/internal/domain" | 13 | "bnema.dev/ermite/internal/domain" | ||
| 14 | + | "bnema.dev/ermite/internal/readertest" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | func oidOf(t *testing.T, s string) domain.ObjectID { | 17 | func oidOf(t *testing.T, s string) domain.ObjectID { | ||
| @@ -22,25 +23,25 @@ func oidOf(t *testing.T, s string) domain.ObjectID { | |||||
| 22 | 23 | ||||
| 23 | // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat. | 24 | // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat. | ||
| 24 | type mergeFixture struct { | 25 | type mergeFixture struct { | ||
| 25 | - | f *fixtureRepo | 26 | + | f *readertest.Fixture |
| 26 | a, b, c, d domain.ObjectID | 27 | a, b, c, d domain.ObjectID | ||
| 27 | mainTip, featTip domain.ObjectID | 28 | mainTip, featTip domain.ObjectID | ||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | func newMergeFixture(t *testing.T, data string) mergeFixture { | 31 | func newMergeFixture(t *testing.T, data string) mergeFixture { | ||
| 31 | t.Helper() | 32 | t.Helper() | ||
| 32 | - | f := newFixtureRepo(t, data, "proj") | 33 | + | f := readertest.NewFixture(t, data, "proj") |
| 33 | - | f.write("a.txt", "one\ntwo\nthree\n") | 34 | + | f.Write("a.txt", "one\ntwo\nthree\n") |
| 34 | - | a := f.commit("add a") | 35 | + | a := f.Commit("add a") |
| 35 | - | f.write("b.txt", "bee\n") | 36 | + | f.Write("b.txt", "bee\n") |
| 36 | - | b := f.commit("add b") | 37 | + | b := f.Commit("add b") |
| 37 | - | f.git("update-ref", "refs/heads/main", b) | 38 | + | f.Git("update-ref", "refs/heads/main", b) |
| 38 | - | f.checkoutNew("feat") | 39 | + | f.CheckoutNew("feat") |
| 39 | - | f.write("a.txt", "one\n2\nthree\n") | 40 | + | f.Write("a.txt", "one\n2\nthree\n") |
| 40 | - | c := f.commit("change a") | 41 | + | c := f.Commit("change a") |
| 41 | - | f.write("c.txt", "see\n") | 42 | + | f.Write("c.txt", "see\n") |
| 42 | - | d := f.commit("add c") | 43 | + | d := f.Commit("add c") |
| 43 | - | f.git("update-ref", "refs/heads/feat", d) | 44 | + | f.Git("update-ref", "refs/heads/feat", d) |
| 44 | return mergeFixture{ | 45 | return mergeFixture{ | ||
| 45 | f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d), | 46 | f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d), | ||
| 46 | mainTip: oidOf(t, b), featTip: oidOf(t, d), | 47 | mainTip: oidOf(t, b), featTip: oidOf(t, d), | ||
| @@ -53,27 +54,27 @@ func TestBranchHeadAndHasCommit(t *testing.T) { | |||||
| 53 | m := newMergeFixture(t, data) | 54 | m := newMergeFixture(t, data) | ||
| 54 | t.Cleanup(func() { _ = g.Close(ctx) }) | 55 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 55 | 56 | ||||
| 56 | - | got, err := g.BranchHead(ctx, m.f.name, "feat") | 57 | + | got, err := g.BranchHead(ctx, m.f.Name, "feat") |
| 57 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 58 | assert.Equal(t, m.d, got) | 59 | assert.Equal(t, m.d, got) | ||
| 59 | 60 | ||||
| 60 | for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} { | 61 | for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} { | ||
| 61 | - | _, err = g.BranchHead(ctx, m.f.name, branch) | 62 | + | _, err = g.BranchHead(ctx, m.f.Name, branch) |
| 62 | assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch) | 63 | assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch) | ||
| 63 | } | 64 | } | ||
| 64 | _, err = g.BranchHead(ctx, name(t, "missing"), "main") | 65 | _, err = g.BranchHead(ctx, name(t, "missing"), "main") | ||
| 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | 66 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 66 | 67 | ||||
| 67 | - | ok, err := g.HasCommit(ctx, m.f.name, m.c) | 68 | + | ok, err := g.HasCommit(ctx, m.f.Name, m.c) |
| 68 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| 69 | assert.True(t, ok) | 70 | assert.True(t, ok) | ||
| 70 | - | ok, err = g.HasCommit(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40))) | 71 | + | ok, err = g.HasCommit(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40))) |
| 71 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 72 | assert.False(t, ok) | 73 | assert.False(t, ok) | ||
| 73 | // A tree is not a commit. | 74 | // A tree is not a commit. | ||
| 74 | - | tree, err := g.CommitTree(ctx, m.f.name, m.d) | 75 | + | tree, err := g.CommitTree(ctx, m.f.Name, m.d) |
| 75 | require.NoError(t, err) | 76 | require.NoError(t, err) | ||
| 76 | - | ok, err = g.HasCommit(ctx, m.f.name, tree) | 77 | + | ok, err = g.HasCommit(ctx, m.f.Name, tree) |
| 77 | require.NoError(t, err) | 78 | require.NoError(t, err) | ||
| 78 | assert.False(t, ok) | 79 | assert.False(t, ok) | ||
| 79 | } | 80 | } | ||
| @@ -83,23 +84,23 @@ func TestMergeBaseAndAncestry(t *testing.T) { | |||||
| 83 | ctx := context.Background() | 84 | ctx := context.Background() | ||
| 84 | m := newMergeFixture(t, data) | 85 | m := newMergeFixture(t, data) | ||
| 85 | // A branch that diverged from a. | 86 | // A branch that diverged from a. | ||
| 86 | - | m.f.git("checkout", "--quiet", "-b", "side", m.a.String()) | 87 | + | m.f.Git("checkout", "--quiet", "-b", "side", m.a.String()) |
| 87 | - | m.f.write("s.txt", "side\n") | 88 | + | m.f.Write("s.txt", "side\n") |
| 88 | - | side := oidOf(t, m.f.commit("side")) | 89 | + | side := oidOf(t, m.f.Commit("side")) |
| 89 | - | m.f.git("update-ref", "refs/heads/side", side.String()) | 90 | + | m.f.Git("update-ref", "refs/heads/side", side.String()) |
| 90 | // An unrelated root. | 91 | // An unrelated root. | ||
| 91 | - | m.f.git("checkout", "--quiet", "--orphan", "other") | 92 | + | m.f.Git("checkout", "--quiet", "--orphan", "other") |
| 92 | - | m.f.git("rm", "-rf", "--quiet", ".") | 93 | + | m.f.Git("rm", "-rf", "--quiet", ".") |
| 93 | - | m.f.write("o.txt", "other\n") | 94 | + | m.f.Write("o.txt", "other\n") |
| 94 | - | other := oidOf(t, m.f.commit("other root")) | 95 | + | other := oidOf(t, m.f.Commit("other root")) |
| 95 | 96 | ||||
| 96 | - | base, err := g.MergeBase(ctx, m.f.name, m.mainTip, m.featTip) | 97 | + | base, err := g.MergeBase(ctx, m.f.Name, m.mainTip, m.featTip) |
| 97 | require.NoError(t, err) | 98 | require.NoError(t, err) | ||
| 98 | assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base") | 99 | assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base") | ||
| 99 | - | base, err = g.MergeBase(ctx, m.f.name, side, m.featTip) | 100 | + | base, err = g.MergeBase(ctx, m.f.Name, side, m.featTip) |
| 100 | require.NoError(t, err) | 101 | require.NoError(t, err) | ||
| 101 | assert.Equal(t, m.a, base) | 102 | assert.Equal(t, m.a, base) | ||
| 102 | - | _, err = g.MergeBase(ctx, m.f.name, other, m.featTip) | 103 | + | _, err = g.MergeBase(ctx, m.f.Name, other, m.featTip) |
| 103 | assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history") | 104 | assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history") | ||
| 104 | 105 | ||||
| 105 | for _, tc := range []struct { | 106 | for _, tc := range []struct { | ||
| @@ -112,11 +113,11 @@ func TestMergeBaseAndAncestry(t *testing.T) { | |||||
| 112 | {side, m.featTip, false}, | 113 | {side, m.featTip, false}, | ||
| 113 | {other, m.featTip, false}, | 114 | {other, m.featTip, false}, | ||
| 114 | } { | 115 | } { | ||
| 115 | - | got, err := g.IsAncestor(ctx, m.f.name, tc.anc, tc.desc) | 116 | + | got, err := g.IsAncestor(ctx, m.f.Name, tc.anc, tc.desc) |
| 116 | require.NoError(t, err) | 117 | require.NoError(t, err) | ||
| 117 | assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc) | 118 | assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc) | ||
| 118 | } | 119 | } | ||
| 119 | - | _, err = g.IsAncestor(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40)), m.featTip) | 120 | + | _, err = g.IsAncestor(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)), m.featTip) |
| 120 | assert.Error(t, err, "an unknown commit is an error, not a plain no") | 121 | assert.Error(t, err, "an unknown commit is an error, not a plain no") | ||
| 121 | } | 122 | } | ||
| 122 | 123 | ||||
| @@ -125,7 +126,7 @@ func TestListRange(t *testing.T) { | |||||
| 125 | ctx := context.Background() | 126 | ctx := context.Background() | ||
| 126 | m := newMergeFixture(t, data) | 127 | m := newMergeFixture(t, data) | ||
| 127 | 128 | ||||
| 128 | - | commits, more, err := g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 10) | 129 | + | commits, more, err := g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 10) |
| 129 | require.NoError(t, err) | 130 | require.NoError(t, err) | ||
| 130 | assert.False(t, more) | 131 | assert.False(t, more) | ||
| 131 | require.Len(t, commits, 2) | 132 | require.Len(t, commits, 2) | ||
| @@ -133,21 +134,21 @@ func TestListRange(t *testing.T) { | |||||
| 133 | assert.Equal(t, m.c, commits[1].ID) | 134 | assert.Equal(t, m.c, commits[1].ID) | ||
| 134 | assert.Equal(t, "add c", commits[0].Summary) | 135 | assert.Equal(t, "add c", commits[0].Summary) | ||
| 135 | 136 | ||||
| 136 | - | commits, more, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 1) | 137 | + | commits, more, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 1) |
| 137 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 138 | assert.True(t, more) | 139 | assert.True(t, more) | ||
| 139 | require.Len(t, commits, 1) | 140 | require.Len(t, commits, 1) | ||
| 140 | 141 | ||||
| 141 | - | commits, more, err = g.ListRange(ctx, m.f.name, domain.ObjectID{}, m.featTip, 10) | 142 | + | commits, more, err = g.ListRange(ctx, m.f.Name, domain.ObjectID{}, m.featTip, 10) |
| 142 | require.NoError(t, err) | 143 | require.NoError(t, err) | ||
| 143 | assert.False(t, more) | 144 | assert.False(t, more) | ||
| 144 | assert.Len(t, commits, 4, "a zero exclude lists the whole history") | 145 | assert.Len(t, commits, 4, "a zero exclude lists the whole history") | ||
| 145 | 146 | ||||
| 146 | - | commits, _, err = g.ListRange(ctx, m.f.name, m.featTip, m.mainTip, 10) | 147 | + | commits, _, err = g.ListRange(ctx, m.f.Name, m.featTip, m.mainTip, 10) |
| 147 | require.NoError(t, err) | 148 | require.NoError(t, err) | ||
| 148 | assert.Empty(t, commits) | 149 | assert.Empty(t, commits) | ||
| 149 | 150 | ||||
| 150 | - | _, _, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 0) | 151 | + | _, _, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 0) |
| 151 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | 152 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | ||
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| @@ -156,7 +157,7 @@ func TestDiffCommits(t *testing.T) { | |||||
| 156 | ctx := context.Background() | 157 | ctx := context.Background() | ||
| 157 | m := newMergeFixture(t, data) | 158 | m := newMergeFixture(t, data) | ||
| 158 | 159 | ||||
| 159 | - | stats, diff, err := g.DiffCommits(ctx, m.f.name, m.mainTip, m.featTip, domain.DefaultDiffLimits()) | 160 | + | stats, diff, err := g.DiffCommits(ctx, m.f.Name, m.mainTip, m.featTip, domain.DefaultDiffLimits()) |
| 160 | require.NoError(t, err) | 161 | require.NoError(t, err) | ||
| 161 | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats) | 162 | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats) | ||
| 162 | require.Len(t, diff.Files, 2) | 163 | require.Len(t, diff.Files, 2) | ||
| @@ -168,7 +169,7 @@ func TestDiffCommits(t *testing.T) { | |||||
| 168 | assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1)) | 169 | assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1)) | ||
| 169 | 170 | ||||
| 170 | // The diff between two commits of a branch. | 171 | // The diff between two commits of a branch. | ||
| 171 | - | stats, _, err = g.DiffCommits(ctx, m.f.name, m.c, m.featTip, domain.DefaultDiffLimits()) | 172 | + | stats, _, err = g.DiffCommits(ctx, m.f.Name, m.c, m.featTip, domain.DefaultDiffLimits()) |
| 172 | require.NoError(t, err) | 173 | require.NoError(t, err) | ||
| 173 | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats) | 174 | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats) | ||
| 174 | } | 175 | } | ||
| @@ -178,29 +179,29 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) { | |||||
| 178 | ctx := context.Background() | 179 | ctx := context.Background() | ||
| 179 | m := newMergeFixture(t, data) | 180 | m := newMergeFixture(t, data) | ||
| 180 | 181 | ||||
| 181 | - | tree, err := g.CommitTree(ctx, m.f.name, m.featTip) | 182 | + | tree, err := g.CommitTree(ctx, m.f.Name, m.featTip) |
| 182 | require.NoError(t, err) | 183 | require.NoError(t, err) | ||
| 183 | - | assert.Equal(t, m.f.git("rev-parse", m.featTip.String()+"^{tree}"), tree.String()) | 184 | + | assert.Equal(t, m.f.Git("rev-parse", m.featTip.String()+"^{tree}"), tree.String()) |
| 184 | 185 | ||||
| 185 | obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" + | 186 | obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" + | ||
| 186 | "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n" | 187 | "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n" | ||
| 187 | - | id, err := g.WriteCommit(ctx, m.f.name, []byte(obj)) | 188 | + | id, err := g.WriteCommit(ctx, m.f.Name, []byte(obj)) |
| 188 | require.NoError(t, err) | 189 | require.NoError(t, err) | ||
| 189 | - | assert.Equal(t, "commit", m.f.git("cat-file", "-t", id.String())) | 190 | + | assert.Equal(t, "commit", m.f.Git("cat-file", "-t", id.String())) |
| 190 | - | assert.Equal(t, "squashed", m.f.git("log", "-1", "--format=%s", id.String())) | 191 | + | assert.Equal(t, "squashed", m.f.Git("log", "-1", "--format=%s", id.String())) |
| 191 | 192 | ||||
| 192 | - | _, err = g.WriteCommit(ctx, m.f.name, []byte("not a commit")) | 193 | + | _, err = g.WriteCommit(ctx, m.f.Name, []byte("not a commit")) |
| 193 | assert.Error(t, err, "git checks the object") | 194 | assert.Error(t, err, "git checks the object") | ||
| 194 | 195 | ||||
| 195 | // The old value is a precondition. | 196 | // The old value is a precondition. | ||
| 196 | - | err = g.UpdateBranch(ctx, m.f.name, "main", m.featTip, id) | 197 | + | err = g.UpdateBranch(ctx, m.f.Name, "main", m.featTip, id) |
| 197 | assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat") | 198 | assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat") | ||
| 198 | - | assert.Equal(t, m.mainTip.String(), m.f.git("rev-parse", "refs/heads/main")) | 199 | + | assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main")) |
| 199 | 200 | ||||
| 200 | - | require.NoError(t, g.UpdateBranch(ctx, m.f.name, "main", m.mainTip, id)) | 201 | + | require.NoError(t, g.UpdateBranch(ctx, m.f.Name, "main", m.mainTip, id)) |
| 201 | - | assert.Equal(t, id.String(), m.f.git("rev-parse", "refs/heads/main")) | 202 | + | assert.Equal(t, id.String(), m.f.Git("rev-parse", "refs/heads/main")) |
| 202 | 203 | ||||
| 203 | - | err = g.UpdateBranch(ctx, m.f.name, "refs/heads/main", id, m.mainTip) | 204 | + | err = g.UpdateBranch(ctx, m.f.Name, "refs/heads/main", id, m.mainTip) |
| 204 | assert.Error(t, err, "only short branch names") | 205 | assert.Error(t, err, "only short branch names") | ||
| 205 | err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip) | 206 | err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip) | ||
| 206 | assert.ErrorIs(t, err, domain.ErrNotFound) | 207 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
M internal/adapters/gitcli/pool_test.go +47 -46
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | 20 | ||||
| 21 | "bnema.dev/ermite/internal/adapters/gitcli" | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | ||
| 22 | "bnema.dev/ermite/internal/domain" | 22 | "bnema.dev/ermite/internal/domain" | ||
| 23 | + | "bnema.dev/ermite/internal/readertest" | |||
| 23 | ) | 24 | ) | ||
| 24 | 25 | ||||
| 25 | // alive reports whether a process still exists. The adapter collects its | 26 | // alive reports whether a process still exists. The adapter collects its | ||
| @@ -43,17 +44,17 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||||
| 43 | // The default idle timeout (60 s) never fires during the test: the hooks | 44 | // The default idle timeout (60 s) never fires during the test: the hooks | ||
| 44 | // do what the timers would, deterministically. | 45 | // do what the timers would, deterministically. | ||
| 45 | g, data := newReader(t, nil) | 46 | g, data := newReader(t, nil) | ||
| 46 | - | h := newHistory(t, data) | 47 | + | h := readertest.NewHistory(t, data) |
| 47 | 48 | ||||
| 48 | assert.Empty(t, g.CatFilePIDs(), "nothing runs before the first read") | 49 | assert.Empty(t, g.CatFilePIDs(), "nothing runs before the first read") | ||
| 49 | - | _, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | 50 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 50 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| 51 | pids := g.CatFilePIDs() | 52 | pids := g.CatFilePIDs() | ||
| 52 | require.Len(t, pids, 1) | 53 | require.Len(t, pids, 1) | ||
| 53 | require.True(t, alive(pids[0])) | 54 | require.True(t, alive(pids[0])) | ||
| 54 | 55 | ||||
| 55 | for range 5 { | 56 | for range 5 { | ||
| 56 | - | _, err = g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false) | 57 | + | _, err = g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false) |
| 57 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 58 | } | 59 | } | ||
| 59 | assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request") | 60 | assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request") | ||
| @@ -68,7 +69,7 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||||
| 68 | requireGone(t, pids...) | 69 | requireGone(t, pids...) | ||
| 69 | 70 | ||||
| 70 | // The next request starts a new one. | 71 | // The next request starts a new one. | ||
| 71 | - | _, err = g.ResolveRevision(ctx, h.name, domain.Revision{}) | 72 | + | _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 72 | require.NoError(t, err) | 73 | require.NoError(t, err) | ||
| 73 | next := g.CatFilePIDs() | 74 | next := g.CatFilePIDs() | ||
| 74 | require.Len(t, next, 1) | 75 | require.Len(t, next, 1) | ||
| @@ -78,8 +79,8 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||||
| 78 | func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) { | 79 | func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) { | ||
| 79 | ctx := context.Background() | 80 | ctx := context.Background() | ||
| 80 | g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 20 * time.Millisecond }) | 81 | g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 20 * time.Millisecond }) | ||
| 81 | - | h := newHistory(t, data) | 82 | + | h := readertest.NewHistory(t, data) |
| 82 | - | _, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | 83 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 83 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 84 | pids := g.CatFilePIDs() | 85 | pids := g.CatFilePIDs() | ||
| 85 | require.Eventually(t, func() bool { return len(g.CatFilePIDs()) == 0 }, 5*time.Second, 5*time.Millisecond) | 86 | require.Eventually(t, func() bool { return len(g.CatFilePIDs()) == 0 }, 5*time.Second, 5*time.Millisecond) | ||
| @@ -89,17 +90,17 @@ func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) { | |||||
| 89 | func TestCloseStopsEveryProcess(t *testing.T) { | 90 | func TestCloseStopsEveryProcess(t *testing.T) { | ||
| 90 | ctx := context.Background() | 91 | ctx := context.Background() | ||
| 91 | g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them | 92 | g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them | ||
| 92 | - | h := newHistory(t, data) | 93 | + | h := readertest.NewHistory(t, data) |
| 93 | - | f2 := newFixtureRepo(t, data, "second") | 94 | + | f2 := readertest.NewFixture(t, data, "second") |
| 94 | - | f2.write("x", "x").commit("x") | 95 | + | f2.Write("x", "x").Commit("x") |
| 95 | - | big := newFixtureRepo(t, data, "bigone") | 96 | + | big := readertest.NewFixture(t, data, "bigone") |
| 96 | - | big.writeBytes("big", randomBytes(600<<10, 1)).commit("big") | 97 | + | big.WriteBytes("big", readertest.RandomBytes(600<<10, 1)).Commit("big") |
| 97 | 98 | ||||
| 98 | - | _, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | 99 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 99 | require.NoError(t, err) | 100 | require.NoError(t, err) | ||
| 100 | - | _, err = g.ResolveRevision(ctx, f2.name, domain.Revision{}) | 101 | + | _, err = g.ResolveRevision(ctx, f2.Name, domain.Revision{}) |
| 101 | require.NoError(t, err) | 102 | require.NoError(t, err) | ||
| 102 | - | _, rc, err := g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big")) | 103 | + | _, rc, err := g.OpenBlob(ctx, big.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 103 | require.NoError(t, err) | 104 | require.NoError(t, err) | ||
| 104 | _ = rc // left open on purpose: Close must stop its process too | 105 | _ = rc // left open on purpose: Close must stop its process too | ||
| 105 | 106 | ||||
| @@ -115,9 +116,9 @@ func TestCloseStopsEveryProcess(t *testing.T) { | |||||
| 115 | requireGone(t, pids...) | 116 | requireGone(t, pids...) | ||
| 116 | assert.Empty(t, g.CatFilePIDs()) | 117 | assert.Empty(t, g.CatFilePIDs()) | ||
| 117 | 118 | ||||
| 118 | - | _, err = g.ResolveRevision(ctx, h.name, domain.Revision{}) | 119 | + | _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 119 | assert.ErrorIs(t, err, gitcli.ErrClosed) | 120 | assert.ErrorIs(t, err, gitcli.ErrClosed) | ||
| 120 | - | _, _, err = g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big")) | 121 | + | _, _, err = g.OpenBlob(ctx, big.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 121 | assert.ErrorIs(t, err, gitcli.ErrClosed) | 122 | assert.ErrorIs(t, err, gitcli.ErrClosed) | ||
| 122 | assert.NoError(t, g.Close(ctx), "Close is idempotent") | 123 | assert.NoError(t, g.Close(ctx), "Close is idempotent") | ||
| 123 | _, err = io.Copy(io.Discard, rc) | 124 | _, err = io.Copy(io.Discard, rc) | ||
| @@ -131,9 +132,9 @@ func TestPoolIsBounded(t *testing.T) { | |||||
| 131 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 }) | 132 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 }) | ||
| 132 | var names []domain.RepoName | 133 | var names []domain.RepoName | ||
| 133 | for i := range 6 { | 134 | for i := range 6 { | ||
| 134 | - | f := newFixtureRepo(t, data, fmt.Sprintf("r%d", i)) | 135 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) |
| 135 | - | f.write("x", "x").commit("x") | 136 | + | f.Write("x", "x").Commit("x") |
| 136 | - | names = append(names, f.name) | 137 | + | names = append(names, f.Name) |
| 137 | } | 138 | } | ||
| 138 | var seen []int | 139 | var seen []int | ||
| 139 | for _, n := range names { | 140 | for _, n := range names { | ||
| @@ -158,9 +159,9 @@ func TestPoolIsBounded(t *testing.T) { | |||||
| 158 | func TestPoolRestartsAfterProcessDeath(t *testing.T) { | 159 | func TestPoolRestartsAfterProcessDeath(t *testing.T) { | ||
| 159 | ctx := context.Background() | 160 | ctx := context.Background() | ||
| 160 | g, data := newReader(t, nil) | 161 | g, data := newReader(t, nil) | ||
| 161 | - | h := newHistory(t, data) | 162 | + | h := readertest.NewHistory(t, data) |
| 162 | 163 | ||||
| 163 | - | _, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | 164 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 164 | require.NoError(t, err) | 165 | require.NoError(t, err) | ||
| 165 | old := g.CatFilePIDs() | 166 | old := g.CatFilePIDs() | ||
| 166 | require.Len(t, old, 1) | 167 | require.Len(t, old, 1) | ||
| @@ -168,9 +169,9 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||||
| 168 | requireGone(t, old[0]) | 169 | requireGone(t, old[0]) | ||
| 169 | 170 | ||||
| 170 | // The request that finds the dead process succeeds on a fresh one. | 171 | // The request that finds the dead process succeeds on a fresh one. | ||
| 171 | - | got, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | 172 | + | got, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 172 | require.NoError(t, err) | 173 | require.NoError(t, err) | ||
| 173 | - | assert.Equal(t, h.merge, got.CommitID.String()) | 174 | + | assert.Equal(t, h.Merge, got.CommitID.String()) |
| 174 | now := g.CatFilePIDs() | 175 | now := g.CatFilePIDs() | ||
| 175 | require.Len(t, now, 1) | 176 | require.Len(t, now, 1) | ||
| 176 | assert.NotEqual(t, old, now) | 177 | assert.NotEqual(t, old, now) | ||
| @@ -179,7 +180,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||||
| 179 | func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | 180 | func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | ||
| 180 | ctx := context.Background() | 181 | ctx := context.Background() | ||
| 181 | g, data := newReader(t, nil) | 182 | g, data := newReader(t, nil) | ||
| 182 | - | h := newHistory(t, data) | 183 | + | h := readertest.NewHistory(t, data) |
| 183 | 184 | ||||
| 184 | var wg sync.WaitGroup | 185 | var wg sync.WaitGroup | ||
| 185 | errs := make(chan error, 24*10) | 186 | errs := make(chan error, 24*10) | ||
| @@ -188,13 +189,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||||
| 188 | for j := range 10 { | 189 | for j := range 10 { | ||
| 189 | switch (i + j) % 4 { | 190 | switch (i + j) % 4 { | ||
| 190 | case 0: | 191 | case 0: | ||
| 191 | - | res, err := g.ResolveRevision(ctx, h.name, rev(t, "feature/x")) | 192 | + | res, err := g.ResolveRevision(ctx, h.Name, readertest.Rev(t, "feature/x")) |
| 192 | - | if err == nil && res.CommitID.String() != h.side { | 193 | + | if err == nil && res.CommitID.String() != h.Side { |
| 193 | err = fmt.Errorf("resolved %s", res.CommitID) | 194 | err = fmt.Errorf("resolved %s", res.CommitID) | ||
| 194 | } | 195 | } | ||
| 195 | errs <- err | 196 | errs <- err | ||
| 196 | case 1: | 197 | case 1: | ||
| 197 | - | info, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, "b.txt")) | 198 | + | info, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, readertest.TreePath(t, "b.txt")) |
| 198 | if err == nil { | 199 | if err == nil { | ||
| 199 | var body []byte | 200 | var body []byte | ||
| 200 | body, err = io.ReadAll(rc) | 201 | body, err = io.ReadAll(rc) | ||
| @@ -205,13 +206,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||||
| 205 | } | 206 | } | ||
| 206 | errs <- err | 207 | errs <- err | ||
| 207 | case 2: | 208 | case 2: | ||
| 208 | - | tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false) | 209 | + | tree, err := g.GetTree(ctx, h.Name, readertest.Rev(t, "v2"), readertest.TreePath(t, "d"), false) |
| 209 | if err == nil && len(tree.Entries) != 2 { | 210 | if err == nil && len(tree.Entries) != 2 { | ||
| 210 | err = fmt.Errorf("tree of %d entries", len(tree.Entries)) | 211 | err = fmt.Errorf("tree of %d entries", len(tree.Entries)) | ||
| 211 | } | 212 | } | ||
| 212 | errs <- err | 213 | errs <- err | ||
| 213 | default: | 214 | default: | ||
| 214 | - | _, err := g.ResolveRefPath(ctx, h.name, "feature/x/d/f") | 215 | + | _, err := g.ResolveRefPath(ctx, h.Name, "feature/x/d/f") |
| 215 | errs <- err | 216 | errs <- err | ||
| 216 | } | 217 | } | ||
| 217 | } | 218 | } | ||
| @@ -227,7 +228,7 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||||
| 227 | 228 | ||||
| 228 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | 229 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | ||
| 229 | g, data := newReader(t, nil) | 230 | g, data := newReader(t, nil) | ||
| 230 | - | h := newHistory(t, data) | 231 | + | h := readertest.NewHistory(t, data) |
| 231 | 232 | ||||
| 232 | // Many requests, some with a context that dies at a random point. Whatever | 233 | // Many requests, some with a context that dies at a random point. Whatever | ||
| 233 | // state a killed process was in, later requests must get right answers. | 234 | // state a killed process was in, later requests must get right answers. | ||
| @@ -236,10 +237,10 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | |||||
| 236 | wg.Go(func() { | 237 | wg.Go(func() { | ||
| 237 | for j := range 20 { | 238 | for j := range 20 { | ||
| 238 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration((i*7+j*3)%5)*time.Millisecond) | 239 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration((i*7+j*3)%5)*time.Millisecond) | ||
| 239 | - | res, err := g.ResolveRevision(ctx, h.name, rev(t, "v2")) | 240 | + | res, err := g.ResolveRevision(ctx, h.Name, readertest.Rev(t, "v2")) |
| 240 | cancel() | 241 | cancel() | ||
| 241 | if err == nil { | 242 | if err == nil { | ||
| 242 | - | assert.Equal(t, h.c2, res.CommitID.String()) | 243 | + | assert.Equal(t, h.C2, res.CommitID.String()) |
| 243 | } else { | 244 | } else { | ||
| 244 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err) | 245 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err) | ||
| 245 | } | 246 | } | ||
| @@ -247,18 +248,18 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | |||||
| 247 | }) | 248 | }) | ||
| 248 | } | 249 | } | ||
| 249 | wg.Wait() | 250 | wg.Wait() | ||
| 250 | - | res, err := g.ResolveRevision(context.Background(), h.name, rev(t, "v2")) | 251 | + | res, err := g.ResolveRevision(context.Background(), h.Name, readertest.Rev(t, "v2")) |
| 251 | require.NoError(t, err) | 252 | require.NoError(t, err) | ||
| 252 | - | assert.Equal(t, h.c2, res.CommitID.String()) | 253 | + | assert.Equal(t, h.C2, res.CommitID.String()) |
| 253 | } | 254 | } | ||
| 254 | 255 | ||||
| 255 | func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | 256 | func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | ||
| 256 | g, data := newReader(t, nil) | 257 | g, data := newReader(t, nil) | ||
| 257 | - | f := newFixtureRepo(t, data, "bigone") | 258 | + | f := readertest.NewFixture(t, data, "bigone") |
| 258 | - | f.writeBytes("big", randomBytes(2<<20, 3)).commit("big") | 259 | + | f.WriteBytes("big", readertest.RandomBytes(2<<20, 3)).Commit("big") |
| 259 | 260 | ||||
| 260 | ctx, cancel := context.WithCancel(context.Background()) | 261 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 261 | - | _, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big")) | 262 | + | _, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 262 | require.NoError(t, err) | 263 | require.NoError(t, err) | ||
| 263 | pids := g.BlobStreamPIDs() | 264 | pids := g.BlobStreamPIDs() | ||
| 264 | require.Len(t, pids, 1) | 265 | require.Len(t, pids, 1) | ||
| @@ -273,7 +274,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | |||||
| 273 | assert.Equal(t, 0, g.BlobStreams()) | 274 | assert.Equal(t, 0, g.BlobStreams()) | ||
| 274 | 275 | ||||
| 275 | // Closing early stops the process as well. | 276 | // Closing early stops the process as well. | ||
| 276 | - | _, rc, err = g.OpenBlob(context.Background(), f.name, domain.Revision{}, tpath(t, "big")) | 277 | + | _, rc, err = g.OpenBlob(context.Background(), f.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 277 | require.NoError(t, err) | 278 | require.NoError(t, err) | ||
| 278 | pids = g.BlobStreamPIDs() | 279 | pids = g.BlobStreamPIDs() | ||
| 279 | require.Len(t, pids, 1) | 280 | require.Len(t, pids, 1) | ||
| @@ -284,7 +285,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | |||||
| 284 | // A context that is already cancelled starts nothing. | 285 | // A context that is already cancelled starts nothing. | ||
| 285 | cctx, cancel2 := context.WithCancel(context.Background()) | 286 | cctx, cancel2 := context.WithCancel(context.Background()) | ||
| 286 | cancel2() | 287 | cancel2() | ||
| 287 | - | _, _, err = g.OpenBlob(cctx, f.name, domain.Revision{}, tpath(t, "big")) | 288 | + | _, _, err = g.OpenBlob(cctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 288 | assert.ErrorIs(t, err, context.Canceled) | 289 | assert.ErrorIs(t, err, context.Canceled) | ||
| 289 | assert.Equal(t, 0, g.BlobStreams()) | 290 | assert.Equal(t, 0, g.BlobStreams()) | ||
| 290 | } | 291 | } | ||
| @@ -299,18 +300,18 @@ func TestOpenBlobStreams50MiB(t *testing.T) { | |||||
| 299 | } | 300 | } | ||
| 300 | ctx := context.Background() | 301 | ctx := context.Background() | ||
| 301 | g, data := newReader(t, nil) | 302 | g, data := newReader(t, nil) | ||
| 302 | - | f := newFixtureRepo(t, data, "huge") | 303 | + | f := readertest.NewFixture(t, data, "huge") |
| 303 | const size = 50 << 20 | 304 | const size = 50 << 20 | ||
| 304 | - | content := randomBytes(size, 42) | 305 | + | content := readertest.RandomBytes(size, 42) |
| 305 | want := sha256.Sum256(content) | 306 | want := sha256.Sum256(content) | ||
| 306 | - | f.writeBytes("huge.bin", content).commit("huge") | 307 | + | f.WriteBytes("huge.bin", content).Commit("huge") |
| 307 | content = nil //nolint:ineffassign // let the 50 MiB go before measuring | 308 | content = nil //nolint:ineffassign // let the 50 MiB go before measuring | ||
| 308 | 309 | ||||
| 309 | runtime.GC() | 310 | runtime.GC() | ||
| 310 | var before, after runtime.MemStats | 311 | var before, after runtime.MemStats | ||
| 311 | runtime.ReadMemStats(&before) | 312 | runtime.ReadMemStats(&before) | ||
| 312 | 313 | ||||
| 313 | - | info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "huge.bin")) | 314 | + | info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "huge.bin")) |
| 314 | require.NoError(t, err) | 315 | require.NoError(t, err) | ||
| 315 | assert.Equal(t, int64(size), info.Size) | 316 | assert.Equal(t, int64(size), info.Size) | ||
| 316 | assert.True(t, info.Binary) | 317 | assert.True(t, info.Binary) | ||
| @@ -383,10 +384,10 @@ func TestExecSemaphoreBoundsOneShotCommands(t *testing.T) { | |||||
| 383 | func TestBlobStreamsAreBounded(t *testing.T) { | 384 | func TestBlobStreamsAreBounded(t *testing.T) { | ||
| 384 | ctx := context.Background() | 385 | ctx := context.Background() | ||
| 385 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxBlobStreams = 2 }) | 386 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxBlobStreams = 2 }) | ||
| 386 | - | f := newFixtureRepo(t, data, "bigone") | 387 | + | f := readertest.NewFixture(t, data, "bigone") |
| 387 | - | f.writeBytes("big", randomBytes(600<<10, 5)).commit("big") | 388 | + | f.WriteBytes("big", readertest.RandomBytes(600<<10, 5)).Commit("big") |
| 388 | open := func() (io.ReadCloser, error) { | 389 | open := func() (io.ReadCloser, error) { | ||
| 389 | - | _, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big")) | 390 | + | _, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big")) |
| 390 | return rc, err | 391 | return rc, err | ||
| 391 | } | 392 | } | ||
| 392 | 393 | ||||
M internal/adapters/gitcli/read.go +2 -0
| @@ -235,6 +235,8 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom | |||||
| 235 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | 235 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | ||
| 236 | // first, with up to one `cat-file` request per candidate on the repository's | 236 | // first, with up to one `cat-file` request per candidate on the repository's | ||
| 237 | // process. At most domain.MaxRefPathTries segments are tried. | 237 | // process. At most domain.MaxRefPathTries segments are tried. | ||
| 238 | + | // | |||
| 239 | + | //hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection | |||
| 238 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | 240 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | ||
| 239 | dir, err := g.repoDir(repo) | 241 | dir, err := g.repoDir(repo) | ||
| 240 | if err != nil { | 242 | if err != nil { | ||
A internal/readertest/compare.go +343 -0
| @@ -0,0 +1,343 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package readertest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Budget bounds how many revisions and paths Compare asks about, so that it | |||
| 23 | + | // stays fast on a large repository. | |||
| 24 | + | type Budget struct { | |||
| 25 | + | Revs int // revisions (refs and commits) for trees and blobs | |||
| 26 | + | LogRevs int // revisions for history pages | |||
| 27 | + | Dirs int // directories per revision | |||
| 28 | + | Files int // files per revision | |||
| 29 | + | LogPaths int // paths for path-limited history | |||
| 30 | + | LastTrees int // trees listed with last commits | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // SmallBudget suits the fixtures. | |||
| 34 | + | var SmallBudget = Budget{Revs: 24, LogRevs: 8, Dirs: 10, Files: 10, LogPaths: 6, LastTrees: 6} | |||
| 35 | + | ||||
| 36 | + | // LargeBudget suits a repository of thousands of commits. | |||
| 37 | + | var LargeBudget = Budget{Revs: 4, LogRevs: 2, Dirs: 6, Files: 8, LogPaths: 5, LastTrees: 3} | |||
| 38 | + | ||||
| 39 | + | // errClass names the kind of an error of the port, so that two readers agree | |||
| 40 | + | // on what went wrong without agreeing on the message. | |||
| 41 | + | func errClass(err error) string { | |||
| 42 | + | switch { | |||
| 43 | + | case err == nil: | |||
| 44 | + | return "ok" | |||
| 45 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 46 | + | return "not found" | |||
| 47 | + | case errors.Is(err, domain.ErrNotFile): | |||
| 48 | + | return "not a file" | |||
| 49 | + | case errors.Is(err, domain.ErrNotDirectory): | |||
| 50 | + | return "not a directory" | |||
| 51 | + | case errors.Is(err, domain.ErrInvalidPageSize): | |||
| 52 | + | return "invalid page size" | |||
| 53 | + | case errors.Is(err, domain.ErrInvalidPath): | |||
| 54 | + | return "invalid path" | |||
| 55 | + | case errors.Is(err, context.Canceled): | |||
| 56 | + | return "canceled" | |||
| 57 | + | } | |||
| 58 | + | return "other: " + err.Error() | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Queries are the questions put to both readers about one repository. | |||
| 62 | + | type Queries struct { | |||
| 63 | + | Revs []string // revisions as a client writes them | |||
| 64 | + | LogRevs []string | |||
| 65 | + | Dirs []string | |||
| 66 | + | Files []string | |||
| 67 | + | LogPaths []string | |||
| 68 | + | RefPaths []string | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // GitOut runs git in dir and returns its trimmed output. | |||
| 72 | + | func GitOut(t testing.TB, dir string, args ...string) string { | |||
| 73 | + | t.Helper() | |||
| 74 | + | cmd := exec.Command("git", append([]string{"--git-dir=" + dir}, args...)...) | |||
| 75 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "LC_ALL=C"} | |||
| 76 | + | out, err := cmd.Output() | |||
| 77 | + | require.NoError(t, err, "git %s", strings.Join(args, " ")) | |||
| 78 | + | return strings.TrimRight(string(out), "\n") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func lines(s string) []string { | |||
| 82 | + | if s == "" { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | return strings.Split(s, "\n") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // spread picks at most n elements of s, evenly, always the first. | |||
| 89 | + | func spread[T any](s []T, n int) []T { | |||
| 90 | + | if len(s) <= n || n <= 0 { | |||
| 91 | + | return s | |||
| 92 | + | } | |||
| 93 | + | out := make([]T, 0, n) | |||
| 94 | + | for i := range n { | |||
| 95 | + | out = append(out, s[i*len(s)/n]) | |||
| 96 | + | } | |||
| 97 | + | return out | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Derive builds the questions for the repository in dir with git. | |||
| 101 | + | func Derive(t testing.TB, dir string, b Budget) Queries { | |||
| 102 | + | t.Helper() | |||
| 103 | + | var q Queries | |||
| 104 | + | revs := []string{"", "HEAD", "nope", "deadbeefdeadbeef", strings.Repeat("ab", 20), "refs/heads", "refs/nope/x"} | |||
| 105 | + | for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")) { | |||
| 106 | + | revs = append(revs, l) | |||
| 107 | + | if short, ok := strings.CutPrefix(l, "refs/heads/"); ok { | |||
| 108 | + | revs = append(revs, short) | |||
| 109 | + | } else if short, ok := strings.CutPrefix(l, "refs/tags/"); ok { | |||
| 110 | + | revs = append(revs, short) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags")) { | |||
| 114 | + | revs = append(revs, l, l[:7], l[:12]) // tag objects too: they are not commits when they lead elsewhere | |||
| 115 | + | } | |||
| 116 | + | var commits []string | |||
| 117 | + | { | |||
| 118 | + | // An empty repository has no commit: rev-list --all prints nothing. | |||
| 119 | + | cmd := exec.Command("git", "--git-dir="+dir, "rev-list", "--all") | |||
| 120 | + | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | |||
| 121 | + | out, err := cmd.Output() | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | commits = lines(strings.TrimRight(string(out), "\n")) | |||
| 124 | + | } | |||
| 125 | + | for _, c := range spread(commits, b.Revs) { | |||
| 126 | + | revs = append(revs, c, c[:7], c[:10]) | |||
| 127 | + | } | |||
| 128 | + | if len(commits) > 0 { | |||
| 129 | + | tree := GitOut(t, dir, "rev-parse", commits[0]+"^{tree}") | |||
| 130 | + | revs = append(revs, tree, tree[:8]) // a tree is not a commit | |||
| 131 | + | } | |||
| 132 | + | q.Revs = dedupe(revs) | |||
| 133 | + | var logRevs []string | |||
| 134 | + | for _, r := range q.Revs { | |||
| 135 | + | if r == "" || r == "HEAD" || strings.HasPrefix(r, "refs/") || len(r) == 40 { | |||
| 136 | + | logRevs = append(logRevs, r) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | q.LogRevs = spread(logRevs, b.LogRevs+4) | |||
| 140 | + | ||||
| 141 | + | // Paths of the newest commit of HEAD, or of the first commit. | |||
| 142 | + | if len(commits) > 0 { | |||
| 143 | + | var dirs, files []string | |||
| 144 | + | for _, l := range lines(GitOut(t, dir, "ls-tree", "-r", "-t", "--format=%(objecttype) %(path)", commits[0])) { | |||
| 145 | + | typ, p, _ := strings.Cut(l, " ") | |||
| 146 | + | switch typ { | |||
| 147 | + | case "tree": | |||
| 148 | + | dirs = append(dirs, p) | |||
| 149 | + | case "blob": | |||
| 150 | + | files = append(files, p) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | q.Dirs = append([]string{""}, spread(dirs, b.Dirs)...) | |||
| 154 | + | q.Dirs = append(q.Dirs, "nope", "nope/deeper", "*") | |||
| 155 | + | if len(files) > 0 { | |||
| 156 | + | q.Dirs = append(q.Dirs, files[0]) // a file where a directory is wanted | |||
| 157 | + | } | |||
| 158 | + | q.Files = append(spread(files, b.Files), "", "nope", "nope/x") | |||
| 159 | + | if len(dirs) > 0 { | |||
| 160 | + | q.Files = append(q.Files, dirs[0]) // a directory where a file is wanted | |||
| 161 | + | } | |||
| 162 | + | q.LogPaths = append([]string{""}, spread(files, b.LogPaths/2+1)...) | |||
| 163 | + | q.LogPaths = append(q.LogPaths, spread(dirs, b.LogPaths/2+1)...) | |||
| 164 | + | q.LogPaths = append(q.LogPaths, "nope", "*") | |||
| 165 | + | q.LogPaths = dedupe(q.LogPaths) | |||
| 166 | + | } | |||
| 167 | + | for _, r := range q.Revs { | |||
| 168 | + | if r != "" { | |||
| 169 | + | q.RefPaths = append(q.RefPaths, r, r+"/dir/file", r+"/x") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | q.RefPaths = append(q.RefPaths, "", "/", "nope", "nope/x", "a//b", "../x", "main/", "HEAD/x", "refs/heads/main/x") | |||
| 173 | + | q.RefPaths = dedupe(spread(q.RefPaths, 4*b.Revs+12)) | |||
| 174 | + | return q | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func dedupe(s []string) []string { | |||
| 178 | + | seen := make(map[string]bool, len(s)) | |||
| 179 | + | out := s[:0:0] | |||
| 180 | + | for _, v := range s { | |||
| 181 | + | if !seen[v] { | |||
| 182 | + | seen[v] = true | |||
| 183 | + | out = append(out, v) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return out | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Compare asks both readers the same questions about the repository and | |||
| 190 | + | // requires the same answers: the same values, and the same kind of error. want | |||
| 191 | + | // is the oracle (the CLI reader). | |||
| 192 | + | func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir string, b Budget) { | |||
| 193 | + | t.Helper() | |||
| 194 | + | ctx := context.Background() | |||
| 195 | + | q := Derive(t, dir, b) | |||
| 196 | + | t.Logf("%d revisions, %d dirs, %d files, %d log paths", len(q.Revs), len(q.Dirs), len(q.Files), len(q.LogPaths)) | |||
| 197 | + | ||||
| 198 | + | t.Run("ListRefs", func(t *testing.T) { | |||
| 199 | + | w, werr := want.ListRefs(ctx, repo) | |||
| 200 | + | g, gerr := got.ListRefs(ctx, repo) | |||
| 201 | + | require.Equal(t, errClass(werr), errClass(gerr)) | |||
| 202 | + | assert.Equal(t, w, g) | |||
| 203 | + | }) | |||
| 204 | + | t.Run("ResolveRevision", func(t *testing.T) { | |||
| 205 | + | for _, s := range q.Revs { | |||
| 206 | + | rev, err := domain.ParseRevision(s) | |||
| 207 | + | if err != nil { | |||
| 208 | + | continue | |||
| 209 | + | } | |||
| 210 | + | w, werr := want.ResolveRevision(ctx, repo, rev) | |||
| 211 | + | g, gerr := got.ResolveRevision(ctx, repo, rev) | |||
| 212 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "revision %q", s) { | |||
| 213 | + | assert.Equal(t, w, g, "revision %q", s) | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | }) | |||
| 217 | + | t.Run("ResolveRefPath", func(t *testing.T) { | |||
| 218 | + | for _, p := range q.RefPaths { | |||
| 219 | + | w, werr := want.ResolveRefPath(ctx, repo, p) | |||
| 220 | + | g, gerr := got.ResolveRefPath(ctx, repo, p) | |||
| 221 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "path %q", p) { | |||
| 222 | + | assert.Equal(t, w, g, "path %q", p) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | }) | |||
| 226 | + | revs := make([]domain.Revision, 0, len(q.Revs)) | |||
| 227 | + | for _, s := range spread(q.Revs, b.Revs+8) { | |||
| 228 | + | if rev, err := domain.ParseRevision(s); err == nil { | |||
| 229 | + | revs = append(revs, rev) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | t.Run("GetTree", func(t *testing.T) { | |||
| 233 | + | last := 0 | |||
| 234 | + | for _, rev := range revs { | |||
| 235 | + | for _, p := range q.Dirs { | |||
| 236 | + | path, err := domain.ParseTreePath(p) | |||
| 237 | + | if err != nil { | |||
| 238 | + | continue | |||
| 239 | + | } | |||
| 240 | + | w, werr := want.GetTree(ctx, repo, rev, path, false) | |||
| 241 | + | g, gerr := got.GetTree(ctx, repo, rev, path, false) | |||
| 242 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "tree %q at %q", p, rev) { | |||
| 243 | + | assert.Equal(t, w, g, "tree %q at %q", p, rev) | |||
| 244 | + | } | |||
| 245 | + | // Last commits for the first few revisions only: each is a history walk. | |||
| 246 | + | if werr == nil && last < b.LastTrees*3 && len(w.Entries) > 0 { | |||
| 247 | + | last++ | |||
| 248 | + | w, werr := want.GetTree(ctx, repo, rev, path, true) | |||
| 249 | + | g, gerr := got.GetTree(ctx, repo, rev, path, true) | |||
| 250 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "tree+last %q at %q", p, rev) { | |||
| 251 | + | assert.Equal(t, w, g, "tree+last %q at %q", p, rev) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | }) | |||
| 257 | + | t.Run("OpenBlob", func(t *testing.T) { | |||
| 258 | + | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | |||
| 259 | + | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | |||
| 260 | + | if err != nil { | |||
| 261 | + | require.Nil(t, rc) | |||
| 262 | + | return info, nil, err | |||
| 263 | + | } | |||
| 264 | + | body, rerr := io.ReadAll(rc) | |||
| 265 | + | require.NoError(t, rerr) | |||
| 266 | + | require.NoError(t, rc.Close()) | |||
| 267 | + | return info, body, nil | |||
| 268 | + | } | |||
| 269 | + | for _, rev := range spread(revs, b.Revs/2+4) { | |||
| 270 | + | for _, p := range q.Files { | |||
| 271 | + | path, err := domain.ParseTreePath(p) | |||
| 272 | + | if err != nil { | |||
| 273 | + | continue | |||
| 274 | + | } | |||
| 275 | + | wi, wb, werr := read(want, rev, path) | |||
| 276 | + | gi, gb, gerr := read(got, rev, path) | |||
| 277 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "blob %q at %q", p, rev) && werr == nil { | |||
| 278 | + | assert.Equal(t, wi, gi, "blob %q at %q", p, rev) | |||
| 279 | + | assert.True(t, bytes.Equal(wb, gb), "content of %q at %q differs", p, rev) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | }) | |||
| 284 | + | t.Run("ListCommits", func(t *testing.T) { | |||
| 285 | + | pages := [...][2]int{{0, 1000}, {0, 3}, {2, 3}, {5, 2}, {40, 7}, {5000, 20}} | |||
| 286 | + | ids := func(p ports.CommitPage) string { | |||
| 287 | + | var sb strings.Builder | |||
| 288 | + | for _, c := range p.Commits { | |||
| 289 | + | sb.WriteString(c.ID.String()[:8] + " ") | |||
| 290 | + | } | |||
| 291 | + | return sb.String() | |||
| 292 | + | } | |||
| 293 | + | for _, s := range q.LogRevs { | |||
| 294 | + | rev, err := domain.ParseRevision(s) | |||
| 295 | + | if err != nil { | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | for _, p := range q.LogPaths { | |||
| 299 | + | path, err := domain.ParseTreePath(p) | |||
| 300 | + | if err != nil { | |||
| 301 | + | continue | |||
| 302 | + | } | |||
| 303 | + | for _, pg := range pages { | |||
| 304 | + | cq := ports.CommitQuery{Revision: rev, Path: path, Skip: pg[0], Limit: pg[1]} | |||
| 305 | + | w, werr := want.ListCommits(ctx, repo, cq) | |||
| 306 | + | g, gerr := got.ListCommits(ctx, repo, cq) | |||
| 307 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "log %q -- %q %v", s, p, pg) { | |||
| 308 | + | if !assert.Equal(t, w, g, "log %q -- %q %v\nwant %s\n got %s", s, p, pg, ids(w), ids(g)) { | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | for _, cq := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} { | |||
| 316 | + | _, werr := want.ListCommits(ctx, repo, cq) | |||
| 317 | + | _, gerr := got.ListCommits(ctx, repo, cq) | |||
| 318 | + | assert.Equal(t, errClass(werr), errClass(gerr), "%+v", cq) | |||
| 319 | + | } | |||
| 320 | + | }) | |||
| 321 | + | t.Run("missing repository", func(t *testing.T) { | |||
| 322 | + | nope := RepoName(t, "no-such-repo") | |||
| 323 | + | for name, call := range map[string]func(ports.RepoReader) error{ | |||
| 324 | + | "ListRefs": func(r ports.RepoReader) error { _, err := r.ListRefs(ctx, nope); return err }, | |||
| 325 | + | "ResolveRevision": func(r ports.RepoReader) error { _, err := r.ResolveRevision(ctx, nope, domain.Revision{}); return err }, | |||
| 326 | + | "ResolveRefPath": func(r ports.RepoReader) error { _, err := r.ResolveRefPath(ctx, nope, "main"); return err }, | |||
| 327 | + | "GetTree": func(r ports.RepoReader) error { | |||
| 328 | + | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | |||
| 329 | + | return err | |||
| 330 | + | }, | |||
| 331 | + | "OpenBlob": func(r ports.RepoReader) error { | |||
| 332 | + | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | |||
| 333 | + | return err | |||
| 334 | + | }, | |||
| 335 | + | "ListCommits": func(r ports.RepoReader) error { | |||
| 336 | + | _, err := r.ListCommits(ctx, nope, ports.CommitQuery{Limit: 1}) | |||
| 337 | + | return err | |||
| 338 | + | }, | |||
| 339 | + | } { | |||
| 340 | + | assert.Equal(t, errClass(call(want)), errClass(call(got)), name) | |||
| 341 | + | } | |||
| 342 | + | }) | |||
| 343 | + | } | |||
R internal/adapters/gitcli/read_test.go → internal/readertest/conformance.go +224 -238 renamed, 55% similar
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package gitcli_test | 3 | + | package readertest |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -18,74 +18,61 @@ import ( | |||||
| 18 | "bnema.dev/ermite/internal/ports" | 18 | "bnema.dev/ermite/internal/ports" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | - | func rev(t *testing.T, s string) domain.Revision { | 21 | + | // Maker builds a RepoReader serving the repositories below data/repos, and |
| 22 | + | // returns it with the data directory. The reader is closed when the test ends. | |||
| 23 | + | type Maker func(t *testing.T) (ports.RepoReader, string) | |||
| 24 | + | ||||
| 25 | + | // Conformance runs the RepoReader contract against the readers mk builds: | |||
| 26 | + | // every adapter that serves repositories must pass it, whatever its route. | |||
| 27 | + | func Conformance(t *testing.T, mk Maker) { | |||
| 28 | + | for _, tc := range []struct { | |||
| 29 | + | name string | |||
| 30 | + | fn func(*testing.T, Maker) | |||
| 31 | + | }{ | |||
| 32 | + | {"ListRefs", testListRefs}, | |||
| 33 | + | {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing}, | |||
| 34 | + | {"DetachedHead", testDetachedHead}, | |||
| 35 | + | {"ResolveRevision", testResolveRevision}, | |||
| 36 | + | {"ResolveRefPath", testResolveRefPath}, | |||
| 37 | + | {"GetTree", testGetTree}, | |||
| 38 | + | {"GetTreeLastCommit", testGetTreeLastCommit}, | |||
| 39 | + | {"GetTreeLastCommitBound", testGetTreeLastCommitBound}, | |||
| 40 | + | {"ListCommits", testListCommits}, | |||
| 41 | + | {"OpenBlob", testOpenBlob}, | |||
| 42 | + | {"OpenBlobMediaTypes", testOpenBlobMediaTypes}, | |||
| 43 | + | {"BinaryRuleLooksAtFirst8000Bytes", testBinaryRuleLooksAtFirst8000Bytes}, | |||
| 44 | + | {"OpenBlobLargeThroughStream", testOpenBlobLargeThroughStream}, | |||
| 45 | + | {"CancelledContext", testCancelledContext}, | |||
| 46 | + | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | |||
| 47 | + | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | |||
| 48 | + | {"GetTreeIsBounded", testGetTreeIsBounded}, | |||
| 49 | + | } { | |||
| 50 | + | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Rev parses a revision. | |||
| 55 | + | func Rev(t *testing.T, s string) domain.Revision { | |||
| 22 | t.Helper() | 56 | t.Helper() | ||
| 23 | r, err := domain.ParseRevision(s) | 57 | r, err := domain.ParseRevision(s) | ||
| 24 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 25 | return r | 59 | return r | ||
| 26 | } | 60 | } | ||
| 27 | 61 | ||||
| 28 | - | func tpath(t *testing.T, s string) domain.TreePath { | 62 | + | // TreePath parses a tree path. |
| 63 | + | func TreePath(t *testing.T, s string) domain.TreePath { | |||
| 29 | t.Helper() | 64 | t.Helper() | ||
| 30 | p, err := domain.ParseTreePath(s) | 65 | p, err := domain.ParseTreePath(s) | ||
| 31 | require.NoError(t, err) | 66 | require.NoError(t, err) | ||
| 32 | return p | 67 | return p | ||
| 33 | } | 68 | } | ||
| 34 | 69 | ||||
| 35 | - | // history is the repository of the read tests: three commits on main, a | 70 | + | func testListRefs(t *testing.T, mk Maker) { |
| 36 | - | // branch with a slash, a merge, lightweight and annotated tags. | |||
| 37 | - | type history struct { | |||
| 38 | - | *fixtureRepo | |||
| 39 | - | c1, c2, c3, side, merge string | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func newHistory(t *testing.T, data string) *history { | |||
| 43 | - | t.Helper() | |||
| 44 | - | f := newFixtureRepo(t, data, "hist") | |||
| 45 | - | h := &history{fixtureRepo: f} | |||
| 46 | - | f.write("a.txt", "one\ntwo\nthree\n"). | |||
| 47 | - | writeBytes("bin", []byte{0, 1}). | |||
| 48 | - | write("d/f", "hi\n"). | |||
| 49 | - | write("d/sub/g", "deep\n"). | |||
| 50 | - | write("we:ird*.txt", "x"). | |||
| 51 | - | write("README.md", "# hello\n"). | |||
| 52 | - | write("empty", "") | |||
| 53 | - | h.c1 = f.commit("first\n\nbody of the first commit\n") | |||
| 54 | - | ||||
| 55 | - | f.mv("a.txt", "b.txt").write("b.txt", "one\ntwo\nthree\nfour\n"). | |||
| 56 | - | writeBytes("bin", []byte{0, 2}). | |||
| 57 | - | chmod("d/f", 0o755). | |||
| 58 | - | write("n.txt", "new\nnonl"). | |||
| 59 | - | remove("README.md") | |||
| 60 | - | h.c2 = f.commit("second") | |||
| 61 | - | ||||
| 62 | - | f.remove("n.txt").symlink("b.txt", "n.txt") | |||
| 63 | - | h.c3 = f.commit("third: type change") | |||
| 64 | - | ||||
| 65 | - | f.checkoutNew("feature/x") | |||
| 66 | - | f.write("side.txt", "s\n") | |||
| 67 | - | h.side = f.commit("side work") | |||
| 68 | - | f.checkout("main") | |||
| 69 | - | f.write("main.txt", "m\n") | |||
| 70 | - | f.commit("main work") | |||
| 71 | - | h.merge = f.merge("feature/x") | |||
| 72 | - | ||||
| 73 | - | f.git("tag", "v1", h.c1) | |||
| 74 | - | f.git("tag", "-a", "-m", "release two", "v2", h.c2) | |||
| 75 | - | f.git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2") | |||
| 76 | - | f.git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.merge+"^{tree}") | |||
| 77 | - | f.git("branch", "release/1.0", h.c2) | |||
| 78 | - | f.git("branch", "rel/1", h.c1) | |||
| 79 | - | f.git("tag", "rel/1/x", h.c3) | |||
| 80 | - | return h | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestListRefs(t *testing.T) { | |||
| 84 | ctx := context.Background() | 71 | ctx := context.Background() | ||
| 85 | - | g, data := newReader(t, nil) | 72 | + | g, data := mk(t) |
| 86 | - | h := newHistory(t, data) | 73 | + | h := NewHistory(t, data) |
| 87 | 74 | ||||
| 88 | - | refs, err := g.ListRefs(ctx, h.name) | 75 | + | refs, err := g.ListRefs(ctx, h.Name) |
| 89 | require.NoError(t, err) | 76 | require.NoError(t, err) | ||
| 90 | 77 | ||||
| 91 | type row struct { | 78 | type row struct { | ||
| @@ -99,27 +86,27 @@ func TestListRefs(t *testing.T) { | |||||
| 99 | for _, r := range refs { | 86 | for _, r := range refs { | ||
| 100 | got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()}) | 87 | got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()}) | ||
| 101 | } | 88 | } | ||
| 102 | - | tagID := func(n string) string { return h.git("rev-parse", "refs/tags/"+n) } | 89 | + | tagID := func(n string) string { return h.Git("rev-parse", "refs/tags/"+n) } |
| 103 | assert.Equal(t, []row{ | 90 | assert.Equal(t, []row{ | ||
| 104 | - | {"HEAD", "main", domain.RefKindHead, h.merge, h.merge}, | 91 | + | {"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge}, |
| 105 | - | {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.side, h.side}, | 92 | + | {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side}, |
| 106 | - | {"refs/heads/main", "main", domain.RefKindBranch, h.merge, h.merge}, | 93 | + | {"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge}, |
| 107 | - | {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.c1, h.c1}, | 94 | + | {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1}, |
| 108 | - | {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.c2, h.c2}, | 95 | + | {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2}, |
| 109 | - | {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.c3, h.c3}, | 96 | + | {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3}, |
| 110 | {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""}, | 97 | {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""}, | ||
| 111 | - | {"refs/tags/v1", "v1", domain.RefKindTag, h.c1, h.c1}, | 98 | + | {"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1}, |
| 112 | - | {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.c2}, | 99 | + | {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2}, |
| 113 | - | {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.c2}, | 100 | + | {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2}, |
| 114 | }, got) | 101 | }, got) | ||
| 115 | } | 102 | } | ||
| 116 | 103 | ||||
| 117 | - | func TestListRefsEmptyAndMissing(t *testing.T) { | 104 | + | func testListRefsEmptyAndMissing(t *testing.T, mk Maker) { |
| 118 | ctx := context.Background() | 105 | ctx := context.Background() | ||
| 119 | - | g, data := newReader(t, nil) | 106 | + | g, data := mk(t) |
| 120 | - | f := newFixtureRepo(t, data, "empty") | 107 | + | f := NewFixture(t, data, "empty") |
| 121 | 108 | ||||
| 122 | - | refs, err := g.ListRefs(ctx, f.name) | 109 | + | refs, err := g.ListRefs(ctx, f.Name) |
| 123 | require.NoError(t, err) | 110 | require.NoError(t, err) | ||
| 124 | require.Len(t, refs, 1) | 111 | require.Len(t, refs, 1) | ||
| 125 | assert.Equal(t, "HEAD", refs[0].Name) | 112 | assert.Equal(t, "HEAD", refs[0].Name) | ||
| @@ -127,85 +114,85 @@ func TestListRefsEmptyAndMissing(t *testing.T) { | |||||
| 127 | assert.True(t, refs[0].ID.IsZero()) | 114 | assert.True(t, refs[0].ID.IsZero()) | ||
| 128 | assert.True(t, refs[0].CommitID.IsZero()) | 115 | assert.True(t, refs[0].CommitID.IsZero()) | ||
| 129 | 116 | ||||
| 130 | - | _, err = g.ListRefs(ctx, name(t, "nope")) | 117 | + | _, err = g.ListRefs(ctx, RepoName(t, "nope")) |
| 131 | assert.ErrorIs(t, err, domain.ErrNotFound) | 118 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 132 | 119 | ||||
| 133 | // No commit to resolve in an empty repository. | 120 | // No commit to resolve in an empty repository. | ||
| 134 | - | _, err = g.ResolveRevision(ctx, f.name, domain.Revision{}) | 121 | + | _, err = g.ResolveRevision(ctx, f.Name, domain.Revision{}) |
| 135 | assert.ErrorIs(t, err, domain.ErrNotFound) | 122 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 136 | - | _, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false) | 123 | + | _, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false) |
| 137 | assert.ErrorIs(t, err, domain.ErrNotFound) | 124 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 138 | - | _, err = g.ListCommits(ctx, f.name, ports.CommitQuery{Limit: 10}) | 125 | + | _, err = g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 10}) |
| 139 | assert.ErrorIs(t, err, domain.ErrNotFound) | 126 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 140 | } | 127 | } | ||
| 141 | 128 | ||||
| 142 | - | func TestDetachedHead(t *testing.T) { | 129 | + | func testDetachedHead(t *testing.T, mk Maker) { |
| 143 | ctx := context.Background() | 130 | ctx := context.Background() | ||
| 144 | - | g, data := newReader(t, nil) | 131 | + | g, data := mk(t) |
| 145 | - | h := newHistory(t, data) | 132 | + | h := NewHistory(t, data) |
| 146 | - | h.git("update-ref", "--no-deref", "HEAD", h.c1) | 133 | + | h.Git("update-ref", "--no-deref", "HEAD", h.C1) |
| 147 | 134 | ||||
| 148 | - | refs, err := g.ListRefs(ctx, h.name) | 135 | + | refs, err := g.ListRefs(ctx, h.Name) |
| 149 | require.NoError(t, err) | 136 | require.NoError(t, err) | ||
| 150 | assert.Equal(t, "HEAD", refs[0].Name) | 137 | assert.Equal(t, "HEAD", refs[0].Name) | ||
| 151 | assert.Empty(t, refs[0].ShortName) | 138 | assert.Empty(t, refs[0].ShortName) | ||
| 152 | } | 139 | } | ||
| 153 | 140 | ||||
| 154 | - | func TestResolveRevision(t *testing.T) { | 141 | + | func testResolveRevision(t *testing.T, mk Maker) { |
| 155 | ctx := context.Background() | 142 | ctx := context.Background() | ||
| 156 | - | g, data := newReader(t, nil) | 143 | + | g, data := mk(t) |
| 157 | - | h := newHistory(t, data) | 144 | + | h := NewHistory(t, data) |
| 158 | 145 | ||||
| 159 | tests := []struct { | 146 | tests := []struct { | ||
| 160 | rev string | 147 | rev string | ||
| 161 | commit string | 148 | commit string | ||
| 162 | ref string | 149 | ref string | ||
| 163 | }{ | 150 | }{ | ||
| 164 | - | {"", h.merge, ""}, | 151 | + | {"", h.Merge, ""}, |
| 165 | - | {"HEAD", h.merge, ""}, | 152 | + | {"HEAD", h.Merge, ""}, |
| 166 | - | {"main", h.merge, "refs/heads/main"}, | 153 | + | {"main", h.Merge, "refs/heads/main"}, |
| 167 | - | {"feature/x", h.side, "refs/heads/feature/x"}, | 154 | + | {"feature/x", h.Side, "refs/heads/feature/x"}, |
| 168 | - | {"refs/heads/feature/x", h.side, "refs/heads/feature/x"}, | 155 | + | {"refs/heads/feature/x", h.Side, "refs/heads/feature/x"}, |
| 169 | - | {"v1", h.c1, "refs/tags/v1"}, | 156 | + | {"v1", h.C1, "refs/tags/v1"}, |
| 170 | - | {"v2", h.c2, "refs/tags/v2"}, | 157 | + | {"v2", h.C2, "refs/tags/v2"}, |
| 171 | - | {"v2-again", h.c2, "refs/tags/v2-again"}, | 158 | + | {"v2-again", h.C2, "refs/tags/v2-again"}, |
| 172 | - | {"refs/tags/v2", h.c2, "refs/tags/v2"}, | 159 | + | {"refs/tags/v2", h.C2, "refs/tags/v2"}, |
| 173 | - | {h.c2, h.c2, ""}, | 160 | + | {h.C2, h.C2, ""}, |
| 174 | - | {h.c2[:7], h.c2, ""}, | 161 | + | {h.C2[:7], h.C2, ""}, |
| 175 | - | {h.c2[:12], h.c2, ""}, | 162 | + | {h.C2[:12], h.C2, ""}, |
| 176 | } | 163 | } | ||
| 177 | for _, tt := range tests { | 164 | for _, tt := range tests { | ||
| 178 | t.Run(tt.rev, func(t *testing.T) { | 165 | t.Run(tt.rev, func(t *testing.T) { | ||
| 179 | - | got, err := g.ResolveRevision(ctx, h.name, rev(t, tt.rev)) | 166 | + | got, err := g.ResolveRevision(ctx, h.Name, Rev(t, tt.rev)) |
| 180 | require.NoError(t, err) | 167 | require.NoError(t, err) | ||
| 181 | assert.Equal(t, tt.commit, got.CommitID.String()) | 168 | assert.Equal(t, tt.commit, got.CommitID.String()) | ||
| 182 | assert.Equal(t, tt.ref, got.Ref) | 169 | assert.Equal(t, tt.ref, got.Ref) | ||
| 183 | }) | 170 | }) | ||
| 184 | } | 171 | } | ||
| 185 | - | for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.c2[:6], "refs/heads", h.git("rev-parse", h.merge+"^{tree}")} { | 172 | + | for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.C2[:6], "refs/heads", h.Git("rev-parse", h.Merge+"^{tree}")} { |
| 186 | t.Run("missing "+bad, func(t *testing.T) { | 173 | t.Run("missing "+bad, func(t *testing.T) { | ||
| 187 | r, err := domain.ParseRevision(bad) | 174 | r, err := domain.ParseRevision(bad) | ||
| 188 | if err != nil { | 175 | if err != nil { | ||
| 189 | return // not a revision at all, rejected before the reader | 176 | return // not a revision at all, rejected before the reader | ||
| 190 | } | 177 | } | ||
| 191 | - | _, err = g.ResolveRevision(ctx, h.name, r) | 178 | + | _, err = g.ResolveRevision(ctx, h.Name, r) |
| 192 | assert.ErrorIs(t, err, domain.ErrNotFound) | 179 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 193 | }) | 180 | }) | ||
| 194 | } | 181 | } | ||
| 195 | 182 | ||||
| 196 | t.Run("a branch wins over a tag of the same name", func(t *testing.T) { | 183 | t.Run("a branch wins over a tag of the same name", func(t *testing.T) { | ||
| 197 | - | h.git("branch", "both", h.c1) | 184 | + | h.Git("branch", "both", h.C1) |
| 198 | - | h.git("tag", "both", h.c2) | 185 | + | h.Git("tag", "both", h.C2) |
| 199 | - | got, err := g.ResolveRevision(ctx, h.name, rev(t, "both")) | 186 | + | got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both")) |
| 200 | require.NoError(t, err) | 187 | require.NoError(t, err) | ||
| 201 | - | assert.Equal(t, h.c1, got.CommitID.String()) | 188 | + | assert.Equal(t, h.C1, got.CommitID.String()) |
| 202 | }) | 189 | }) | ||
| 203 | } | 190 | } | ||
| 204 | 191 | ||||
| 205 | - | func TestResolveRefPath(t *testing.T) { | 192 | + | func testResolveRefPath(t *testing.T, mk Maker) { |
| 206 | ctx := context.Background() | 193 | ctx := context.Background() | ||
| 207 | - | g, data := newReader(t, nil) | 194 | + | g, data := mk(t) |
| 208 | - | h := newHistory(t, data) | 195 | + | h := NewHistory(t, data) |
| 209 | 196 | ||||
| 210 | tests := []struct { | 197 | tests := []struct { | ||
| 211 | path string | 198 | path string | ||
| @@ -214,22 +201,22 @@ func TestResolveRefPath(t *testing.T) { | |||||
| 214 | id string | 201 | id string | ||
| 215 | rest string | 202 | rest string | ||
| 216 | }{ | 203 | }{ | ||
| 217 | - | {"main", "main", "refs/heads/main", h.merge, ""}, | 204 | + | {"main", "main", "refs/heads/main", h.Merge, ""}, |
| 218 | - | {"main/d/f", "main", "refs/heads/main", h.merge, "d/f"}, | 205 | + | {"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"}, |
| 219 | - | {"feature/x", "feature/x", "refs/heads/feature/x", h.side, ""}, | 206 | + | {"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""}, |
| 220 | - | {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.side, "d/f"}, | 207 | + | {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"}, |
| 221 | - | {"feature/x/", "feature/x", "refs/heads/feature/x", h.side, ""}, | 208 | + | {"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""}, |
| 222 | - | {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.c2, "a.txt"}, | 209 | + | {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"}, |
| 223 | // The longest ref wins: the tag rel/1/x over the branch rel/1. | 210 | // The longest ref wins: the tag rel/1/x over the branch rel/1. | ||
| 224 | - | {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.c3, "d/f"}, | 211 | + | {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"}, |
| 225 | - | {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.c1, "d/f"}, | 212 | + | {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"}, |
| 226 | - | {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.c2, "we:ird*.txt"}, | 213 | + | {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"}, |
| 227 | - | {h.c1 + "/d", h.c1, "", h.c1, "d"}, | 214 | + | {h.C1 + "/d", h.C1, "", h.C1, "d"}, |
| 228 | - | {h.c1[:8] + "/d/f", h.c1[:8], "", h.c1, "d/f"}, | 215 | + | {h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"}, |
| 229 | } | 216 | } | ||
| 230 | for _, tt := range tests { | 217 | for _, tt := range tests { | ||
| 231 | t.Run(tt.path, func(t *testing.T) { | 218 | t.Run(tt.path, func(t *testing.T) { | ||
| 232 | - | got, err := g.ResolveRefPath(ctx, h.name, tt.path) | 219 | + | got, err := g.ResolveRefPath(ctx, h.Name, tt.path) |
| 233 | require.NoError(t, err) | 220 | require.NoError(t, err) | ||
| 234 | assert.Equal(t, tt.name, got.Name) | 221 | assert.Equal(t, tt.name, got.Name) | ||
| 235 | assert.Equal(t, tt.ref, got.Ref) | 222 | assert.Equal(t, tt.ref, got.Ref) | ||
| @@ -239,24 +226,24 @@ func TestResolveRefPath(t *testing.T) { | |||||
| 239 | } | 226 | } | ||
| 240 | for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} { | 227 | for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} { | ||
| 241 | t.Run("missing "+bad, func(t *testing.T) { | 228 | t.Run("missing "+bad, func(t *testing.T) { | ||
| 242 | - | _, err := g.ResolveRefPath(ctx, h.name, bad) | 229 | + | _, err := g.ResolveRefPath(ctx, h.Name, bad) |
| 243 | assert.ErrorIs(t, err, domain.ErrNotFound) | 230 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 244 | }) | 231 | }) | ||
| 245 | } | 232 | } | ||
| 246 | - | _, err := g.ResolveRefPath(ctx, name(t, "nope"), "main") | 233 | + | _, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main") |
| 247 | assert.ErrorIs(t, err, domain.ErrNotFound) | 234 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 248 | } | 235 | } | ||
| 249 | 236 | ||||
| 250 | - | func TestGetTree(t *testing.T) { | 237 | + | func testGetTree(t *testing.T, mk Maker) { |
| 251 | ctx := context.Background() | 238 | ctx := context.Background() | ||
| 252 | - | g, data := newReader(t, nil) | 239 | + | g, data := mk(t) |
| 253 | - | h := newHistory(t, data) | 240 | + | h := NewHistory(t, data) |
| 254 | 241 | ||||
| 255 | t.Run("root at HEAD", func(t *testing.T) { | 242 | t.Run("root at HEAD", func(t *testing.T) { | ||
| 256 | - | tree, err := g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false) | 243 | + | tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false) |
| 257 | require.NoError(t, err) | 244 | require.NoError(t, err) | ||
| 258 | - | assert.Equal(t, h.merge, tree.CommitID.String()) | 245 | + | assert.Equal(t, h.Merge, tree.CommitID.String()) |
| 259 | - | assert.Equal(t, h.git("rev-parse", h.merge+"^{tree}"), tree.ID.String()) | 246 | + | assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String()) |
| 260 | type row struct { | 247 | type row struct { | ||
| 261 | name, path string | 248 | name, path string | ||
| 262 | kind domain.EntryKind | 249 | kind domain.EntryKind | ||
| @@ -282,7 +269,7 @@ func TestGetTree(t *testing.T) { | |||||
| 282 | }) | 269 | }) | ||
| 283 | 270 | ||||
| 284 | t.Run("subdirectory with an executable", func(t *testing.T) { | 271 | t.Run("subdirectory with an executable", func(t *testing.T) { | ||
| 285 | - | tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false) | 272 | + | tree, err := g.GetTree(ctx, h.Name, Rev(t, "v2"), TreePath(t, "d"), false) |
| 286 | require.NoError(t, err) | 273 | require.NoError(t, err) | ||
| 287 | require.Len(t, tree.Entries, 2) | 274 | require.Len(t, tree.Entries, 2) | ||
| 288 | assert.Equal(t, "sub", tree.Entries[0].Name) | 275 | assert.Equal(t, "sub", tree.Entries[0].Name) | ||
| @@ -290,42 +277,42 @@ func TestGetTree(t *testing.T) { | |||||
| 290 | assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind) | 277 | assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind) | ||
| 291 | assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode) | 278 | assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode) | ||
| 292 | assert.Equal(t, "d/f", tree.Entries[1].Path) | 279 | assert.Equal(t, "d/f", tree.Entries[1].Path) | ||
| 293 | - | assert.Equal(t, h.c2, tree.CommitID.String()) | 280 | + | assert.Equal(t, h.C2, tree.CommitID.String()) |
| 294 | }) | 281 | }) | ||
| 295 | 282 | ||||
| 296 | t.Run("errors", func(t *testing.T) { | 283 | t.Run("errors", func(t *testing.T) { | ||
| 297 | - | _, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "nope"), false) | 284 | + | _, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "nope"), false) |
| 298 | assert.ErrorIs(t, err, domain.ErrNotFound) | 285 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 299 | - | _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d/nope/x"), false) | 286 | + | _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d/nope/x"), false) |
| 300 | assert.ErrorIs(t, err, domain.ErrNotFound) | 287 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 301 | - | _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"), false) | 288 | + | _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "b.txt"), false) |
| 302 | assert.ErrorIs(t, err, domain.ErrNotDirectory) | 289 | assert.ErrorIs(t, err, domain.ErrNotDirectory) | ||
| 303 | - | _, err = g.GetTree(ctx, h.name, rev(t, "nope"), domain.TreePath{}, false) | 290 | + | _, err = g.GetTree(ctx, h.Name, Rev(t, "nope"), domain.TreePath{}, false) |
| 304 | assert.ErrorIs(t, err, domain.ErrNotFound) | 291 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 305 | }) | 292 | }) | ||
| 306 | 293 | ||||
| 307 | t.Run("paths are literal", func(t *testing.T) { | 294 | t.Run("paths are literal", func(t *testing.T) { | ||
| 308 | - | tree, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d"), true) | 295 | + | tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d"), true) |
| 309 | require.NoError(t, err) | 296 | require.NoError(t, err) | ||
| 310 | require.Len(t, tree.Entries, 2) | 297 | require.Len(t, tree.Entries, 2) | ||
| 311 | - | _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "*"), false) | 298 | + | _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "*"), false) |
| 312 | assert.ErrorIs(t, err, domain.ErrNotFound) | 299 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 313 | }) | 300 | }) | ||
| 314 | } | 301 | } | ||
| 315 | 302 | ||||
| 316 | - | func TestGetTreeLastCommit(t *testing.T) { | 303 | + | func testGetTreeLastCommit(t *testing.T, mk Maker) { |
| 317 | ctx := context.Background() | 304 | ctx := context.Background() | ||
| 318 | - | g, data := newReader(t, nil) | 305 | + | g, data := mk(t) |
| 319 | - | h := newHistory(t, data) | 306 | + | h := NewHistory(t, data) |
| 320 | 307 | ||||
| 321 | - | tree, err := g.GetTree(ctx, h.name, rev(t, "main"), domain.TreePath{}, true) | 308 | + | tree, err := g.GetTree(ctx, h.Name, Rev(t, "main"), domain.TreePath{}, true) |
| 322 | require.NoError(t, err) | 309 | require.NoError(t, err) | ||
| 323 | got := map[string]string{} | 310 | got := map[string]string{} | ||
| 324 | for _, e := range tree.Entries { | 311 | for _, e := range tree.Entries { | ||
| 325 | require.NotNil(t, e.LastCommit, e.Path) | 312 | require.NotNil(t, e.LastCommit, e.Path) | ||
| 326 | got[e.Path] = e.LastCommit.Summary | 313 | got[e.Path] = e.LastCommit.Summary | ||
| 327 | // The oracle: git log -1 for the same path. | 314 | // The oracle: git log -1 for the same path. | ||
| 328 | - | assert.Equal(t, h.git("log", "-1", "--format=%H", h.merge, "--", e.Path), e.LastCommit.ID.String(), e.Path) | 315 | + | assert.Equal(t, h.Git("log", "-1", "--format=%H", h.Merge, "--", e.Path), e.LastCommit.ID.String(), e.Path) |
| 329 | } | 316 | } | ||
| 330 | assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d") | 317 | assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d") | ||
| 331 | assert.Equal(t, "second", got["b.txt"]) | 318 | assert.Equal(t, "second", got["b.txt"]) | ||
| @@ -335,17 +322,17 @@ func TestGetTreeLastCommit(t *testing.T) { | |||||
| 335 | assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name) | 322 | assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name) | ||
| 336 | } | 323 | } | ||
| 337 | 324 | ||||
| 338 | - | func TestGetTreeLastCommitBound(t *testing.T) { | 325 | + | func testGetTreeLastCommitBound(t *testing.T, mk Maker) { |
| 339 | ctx := context.Background() | 326 | ctx := context.Background() | ||
| 340 | - | g, data := newReader(t, nil) | 327 | + | g, data := mk(t) |
| 341 | - | f := newFixtureRepo(t, data, "wide") | 328 | + | f := NewFixture(t, data, "wide") |
| 342 | n := domain.MaxLastCommitEntries + 5 | 329 | n := domain.MaxLastCommitEntries + 5 | ||
| 343 | for i := range n { | 330 | for i := range n { | ||
| 344 | - | f.write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n") | 331 | + | f.Write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n") |
| 345 | } | 332 | } | ||
| 346 | - | f.commit("wide") | 333 | + | f.Commit("wide") |
| 347 | 334 | ||||
| 348 | - | tree, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, true) | 335 | + | tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true) |
| 349 | require.NoError(t, err) | 336 | require.NoError(t, err) | ||
| 350 | require.Len(t, tree.Entries, n) | 337 | require.Len(t, tree.Entries, n) | ||
| 351 | assert.True(t, tree.LastCommitsOmitted) | 338 | assert.True(t, tree.LastCommitsOmitted) | ||
| @@ -359,7 +346,7 @@ func TestGetTreeLastCommitBound(t *testing.T) { | |||||
| 359 | assert.NotNil(t, tree.Entries[0].LastCommit) | 346 | assert.NotNil(t, tree.Entries[0].LastCommit) | ||
| 360 | assert.Nil(t, tree.Entries[n-1].LastCommit) | 347 | assert.Nil(t, tree.Entries[n-1].LastCommit) | ||
| 361 | 348 | ||||
| 362 | - | tree, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false) | 349 | + | tree, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false) |
| 363 | require.NoError(t, err) | 350 | require.NoError(t, err) | ||
| 364 | assert.False(t, tree.LastCommitsOmitted) | 351 | assert.False(t, tree.LastCommitsOmitted) | ||
| 365 | } | 352 | } | ||
| @@ -375,18 +362,18 @@ func itoa(i int) string { | |||||
| 375 | return string(b) | 362 | return string(b) | ||
| 376 | } | 363 | } | ||
| 377 | 364 | ||||
| 378 | - | func TestListCommits(t *testing.T) { | 365 | + | func testListCommits(t *testing.T, mk Maker) { |
| 379 | ctx := context.Background() | 366 | ctx := context.Background() | ||
| 380 | - | g, data := newReader(t, nil) | 367 | + | g, data := mk(t) |
| 381 | - | h := newHistory(t, data) | 368 | + | h := NewHistory(t, data) |
| 382 | 369 | ||||
| 383 | - | oracle := strings.Split(h.git("log", "--format=%H", h.merge), "\n") | 370 | + | oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n") |
| 384 | require.Len(t, oracle, 6) | 371 | require.Len(t, oracle, 6) | ||
| 385 | 372 | ||||
| 386 | t.Run("all", func(t *testing.T) { | 373 | t.Run("all", func(t *testing.T) { | ||
| 387 | - | page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 100}) | 374 | + | page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100}) |
| 388 | require.NoError(t, err) | 375 | require.NoError(t, err) | ||
| 389 | - | assert.Equal(t, h.merge, page.Head.String()) | 376 | + | assert.Equal(t, h.Merge, page.Head.String()) |
| 390 | assert.False(t, page.HasMore) | 377 | assert.False(t, page.HasMore) | ||
| 391 | var ids []string | 378 | var ids []string | ||
| 392 | for _, c := range page.Commits { | 379 | for _, c := range page.Commits { | ||
| @@ -399,7 +386,7 @@ func TestListCommits(t *testing.T) { | |||||
| 399 | var ids []string | 386 | var ids []string | ||
| 400 | skip := 0 | 387 | skip := 0 | ||
| 401 | for { | 388 | for { | ||
| 402 | - | page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Skip: skip, Limit: 2}) | 389 | + | page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Skip: skip, Limit: 2}) |
| 403 | require.NoError(t, err) | 390 | require.NoError(t, err) | ||
| 404 | for _, c := range page.Commits { | 391 | for _, c := range page.Commits { | ||
| 405 | ids = append(ids, c.ID.String()) | 392 | ids = append(ids, c.ID.String()) | ||
| @@ -414,33 +401,33 @@ func TestListCommits(t *testing.T) { | |||||
| 414 | }) | 401 | }) | ||
| 415 | 402 | ||||
| 416 | t.Run("path", func(t *testing.T) { | 403 | t.Run("path", func(t *testing.T) { | ||
| 417 | - | page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "d/f"), Limit: 10}) | 404 | + | page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "d/f"), Limit: 10}) |
| 418 | require.NoError(t, err) | 405 | require.NoError(t, err) | ||
| 419 | var ids []string | 406 | var ids []string | ||
| 420 | for _, c := range page.Commits { | 407 | for _, c := range page.Commits { | ||
| 421 | ids = append(ids, c.ID.String()) | 408 | ids = append(ids, c.ID.String()) | ||
| 422 | } | 409 | } | ||
| 423 | - | assert.Equal(t, strings.Split(h.git("log", "--format=%H", h.merge, "--", "d/f"), "\n"), ids) | 410 | + | assert.Equal(t, strings.Split(h.Git("log", "--format=%H", h.Merge, "--", "d/f"), "\n"), ids) |
| 424 | assert.Len(t, ids, 2) | 411 | assert.Len(t, ids, 2) | ||
| 425 | 412 | ||||
| 426 | - | page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "we:ird*.txt"), Limit: 10}) | 413 | + | page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "we:ird*.txt"), Limit: 10}) |
| 427 | require.NoError(t, err) | 414 | require.NoError(t, err) | ||
| 428 | require.Len(t, page.Commits, 1, "paths are literal") | 415 | require.Len(t, page.Commits, 1, "paths are literal") | ||
| 429 | - | page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "*"), Limit: 10}) | 416 | + | page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "*"), Limit: 10}) |
| 430 | require.NoError(t, err) | 417 | require.NoError(t, err) | ||
| 431 | assert.Empty(t, page.Commits) | 418 | assert.Empty(t, page.Commits) | ||
| 432 | }) | 419 | }) | ||
| 433 | 420 | ||||
| 434 | t.Run("revision", func(t *testing.T) { | 421 | t.Run("revision", func(t *testing.T) { | ||
| 435 | - | page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "v2"), Limit: 10}) | 422 | + | page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "v2"), Limit: 10}) |
| 436 | require.NoError(t, err) | 423 | require.NoError(t, err) | ||
| 437 | require.Len(t, page.Commits, 2) | 424 | require.Len(t, page.Commits, 2) | ||
| 438 | - | assert.Equal(t, h.c2, page.Head.String()) | 425 | + | assert.Equal(t, h.C2, page.Head.String()) |
| 439 | - | assert.Equal(t, h.c2, page.Commits[0].ID.String()) | 426 | + | assert.Equal(t, h.C2, page.Commits[0].ID.String()) |
| 440 | }) | 427 | }) | ||
| 441 | 428 | ||||
| 442 | t.Run("metadata", func(t *testing.T) { | 429 | t.Run("metadata", func(t *testing.T) { | ||
| 443 | - | page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: domain.RevisionOf(mustID(t, h.c1)), Limit: 10}) | 430 | + | page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: domain.RevisionOf(MustID(t, h.C1)), Limit: 10}) |
| 444 | require.NoError(t, err) | 431 | require.NoError(t, err) | ||
| 445 | require.Len(t, page.Commits, 1) | 432 | require.Len(t, page.Commits, 1) | ||
| 446 | c := page.Commits[0] | 433 | c := page.Commits[0] | ||
| @@ -451,25 +438,26 @@ func TestListCommits(t *testing.T) { | |||||
| 451 | assert.Equal(t, "Grace Hopper", c.Committer.Name) | 438 | assert.Equal(t, "Grace Hopper", c.Committer.Name) | ||
| 452 | assert.Equal(t, "grace@example.org", c.Committer.Email) | 439 | assert.Equal(t, "grace@example.org", c.Committer.Email) | ||
| 453 | 440 | ||||
| 454 | - | page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 1}) | 441 | + | page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1}) |
| 455 | require.NoError(t, err) | 442 | require.NoError(t, err) | ||
| 456 | require.Len(t, page.Commits, 1) | 443 | require.Len(t, page.Commits, 1) | ||
| 457 | - | assert.Equal(t, []string{h.git("rev-parse", h.merge+"^1"), h.side}, idStrings(page.Commits[0].Parents)) | 444 | + | assert.Equal(t, []string{h.Git("rev-parse", h.Merge+"^1"), h.Side}, idStrings(page.Commits[0].Parents)) |
| 458 | }) | 445 | }) | ||
| 459 | 446 | ||||
| 460 | t.Run("bad queries", func(t *testing.T) { | 447 | t.Run("bad queries", func(t *testing.T) { | ||
| 461 | for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} { | 448 | for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} { | ||
| 462 | - | _, err := g.ListCommits(ctx, h.name, q) | 449 | + | _, err := g.ListCommits(ctx, h.Name, q) |
| 463 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q) | 450 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q) | ||
| 464 | } | 451 | } | ||
| 465 | - | _, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "nope"), Limit: 1}) | 452 | + | _, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "nope"), Limit: 1}) |
| 466 | assert.ErrorIs(t, err, domain.ErrNotFound) | 453 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 467 | - | _, err = g.ListCommits(ctx, name(t, "nope"), ports.CommitQuery{Limit: 1}) | 454 | + | _, err = g.ListCommits(ctx, RepoName(t, "nope"), ports.CommitQuery{Limit: 1}) |
| 468 | assert.ErrorIs(t, err, domain.ErrNotFound) | 455 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 469 | }) | 456 | }) | ||
| 470 | } | 457 | } | ||
| 471 | 458 | ||||
| 472 | - | func mustID(t *testing.T, s string) domain.ObjectID { | 459 | + | // MustID parses an object id. |
| 460 | + | func MustID(t *testing.T, s string) domain.ObjectID { | |||
| 473 | t.Helper() | 461 | t.Helper() | ||
| 474 | id, err := domain.ParseObjectID(s) | 462 | id, err := domain.ParseObjectID(s) | ||
| 475 | require.NoError(t, err) | 463 | require.NoError(t, err) | ||
| @@ -484,14 +472,14 @@ func idStrings(ids []domain.ObjectID) []string { | |||||
| 484 | return out | 472 | return out | ||
| 485 | } | 473 | } | ||
| 486 | 474 | ||||
| 487 | - | func TestOpenBlob(t *testing.T) { | 475 | + | func testOpenBlob(t *testing.T, mk Maker) { |
| 488 | ctx := context.Background() | 476 | ctx := context.Background() | ||
| 489 | - | g, data := newReader(t, nil) | 477 | + | g, data := mk(t) |
| 490 | - | h := newHistory(t, data) | 478 | + | h := NewHistory(t, data) |
| 491 | 479 | ||||
| 492 | read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) { | 480 | read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) { | ||
| 493 | t.Helper() | 481 | t.Helper() | ||
| 494 | - | info, rc, err := g.OpenBlob(ctx, h.name, rev(t, r), tpath(t, p)) | 482 | + | info, rc, err := g.OpenBlob(ctx, h.Name, Rev(t, r), TreePath(t, p)) |
| 495 | require.NoError(t, err) | 483 | require.NoError(t, err) | ||
| 496 | body, err := io.ReadAll(rc) | 484 | body, err := io.ReadAll(rc) | ||
| 497 | require.NoError(t, err) | 485 | require.NoError(t, err) | ||
| @@ -506,7 +494,7 @@ func TestOpenBlob(t *testing.T) { | |||||
| 506 | assert.Equal(t, uint32(0o100644), info.Mode) | 494 | assert.Equal(t, uint32(0o100644), info.Mode) | ||
| 507 | assert.False(t, info.Binary) | 495 | assert.False(t, info.Binary) | ||
| 508 | assert.Equal(t, "text/plain", info.MediaType) | 496 | assert.Equal(t, "text/plain", info.MediaType) | ||
| 509 | - | assert.Equal(t, h.git("rev-parse", "main:b.txt"), info.ID.String()) | 497 | + | assert.Equal(t, h.Git("rev-parse", "main:b.txt"), info.ID.String()) |
| 510 | 498 | ||||
| 511 | info, body = read(t, "main", "bin") | 499 | info, body = read(t, "main", "bin") | ||
| 512 | assert.Equal(t, "\x00\x02", body) | 500 | assert.Equal(t, "\x00\x02", body) | ||
| @@ -529,30 +517,30 @@ func TestOpenBlob(t *testing.T) { | |||||
| 529 | assert.Equal(t, "text/plain", info.MediaType) | 517 | assert.Equal(t, "text/plain", info.MediaType) | ||
| 530 | 518 | ||||
| 531 | for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} { | 519 | for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} { | ||
| 532 | - | _, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, p)) | 520 | + | _, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, TreePath(t, p)) |
| 533 | assert.ErrorIs(t, err, want, p) | 521 | assert.ErrorIs(t, err, want, p) | ||
| 534 | assert.Nil(t, rc) | 522 | assert.Nil(t, rc) | ||
| 535 | } | 523 | } | ||
| 536 | - | _, _, err := g.OpenBlob(ctx, h.name, rev(t, "nope"), tpath(t, "b.txt")) | 524 | + | _, _, err := g.OpenBlob(ctx, h.Name, Rev(t, "nope"), TreePath(t, "b.txt")) |
| 537 | assert.ErrorIs(t, err, domain.ErrNotFound) | 525 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 538 | } | 526 | } | ||
| 539 | 527 | ||||
| 540 | - | func TestOpenBlobMediaTypes(t *testing.T) { | 528 | + | func testOpenBlobMediaTypes(t *testing.T, mk Maker) { |
| 541 | ctx := context.Background() | 529 | ctx := context.Background() | ||
| 542 | - | g, data := newReader(t, nil) | 530 | + | g, data := mk(t) |
| 543 | - | f := newFixtureRepo(t, data, "media") | 531 | + | f := NewFixture(t, data, "media") |
| 544 | png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") | 532 | png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") | ||
| 545 | - | f.writeBytes("logo.png", png). | 533 | + | f.WriteBytes("logo.png", png). |
| 546 | - | writeBytes("noext", png). | 534 | + | WriteBytes("noext", png). |
| 547 | - | write("page.html", "<!doctype html><html><body>hi</body></html>\n"). | 535 | + | Write("page.html", "<!doctype html><html><body>hi</body></html>\n"). |
| 548 | - | write("style.css", "a { color: red }\n"). | 536 | + | Write("style.css", "a { color: red }\n"). |
| 549 | - | write("data.json", `{"a":1}`+"\n"). | 537 | + | Write("data.json", `{"a":1}`+"\n"). |
| 550 | - | write("notes", "plain words\n"). | 538 | + | Write("notes", "plain words\n"). |
| 551 | - | write("main.go", "package main\n"). | 539 | + | Write("main.go", "package main\n"). |
| 552 | - | writeBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}). | 540 | + | WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}). |
| 553 | - | writeBytes("fake.txt", []byte{'a', 0, 'b'}). | 541 | + | WriteBytes("fake.txt", []byte{'a', 0, 'b'}). |
| 554 | - | write("latin", "caf\xe9\n") | 542 | + | Write("latin", "caf\xe9\n") |
| 555 | - | f.commit("media") | 543 | + | f.Commit("media") |
| 556 | 544 | ||||
| 557 | want := map[string]struct { | 545 | want := map[string]struct { | ||
| 558 | media string | 546 | media string | ||
| @@ -570,7 +558,7 @@ func TestOpenBlobMediaTypes(t *testing.T) { | |||||
| 570 | "latin": {"text/plain", false}, | 558 | "latin": {"text/plain", false}, | ||
| 571 | } | 559 | } | ||
| 572 | for p, w := range want { | 560 | for p, w := range want { | ||
| 573 | - | info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p)) | 561 | + | info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p)) |
| 574 | require.NoError(t, err, p) | 562 | require.NoError(t, err, p) | ||
| 575 | require.NoError(t, rc.Close()) | 563 | require.NoError(t, rc.Close()) | ||
| 576 | assert.Equal(t, w.media, info.MediaType, p) | 564 | assert.Equal(t, w.media, info.MediaType, p) | ||
| @@ -578,69 +566,67 @@ func TestOpenBlobMediaTypes(t *testing.T) { | |||||
| 578 | } | 566 | } | ||
| 579 | } | 567 | } | ||
| 580 | 568 | ||||
| 581 | - | func TestBinaryRuleLooksAtFirst8000Bytes(t *testing.T) { | 569 | + | func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) { |
| 582 | ctx := context.Background() | 570 | ctx := context.Background() | ||
| 583 | - | g, data := newReader(t, nil) | 571 | + | g, data := mk(t) |
| 584 | - | f := newFixtureRepo(t, data, "sniff") | 572 | + | f := NewFixture(t, data, "sniff") |
| 585 | late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen | 573 | late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen | ||
| 586 | early := append([]byte(strings.Repeat("a", 7999)), 0) | 574 | early := append([]byte(strings.Repeat("a", 7999)), 0) | ||
| 587 | - | f.writeBytes("late", late).writeBytes("early", early) | 575 | + | f.WriteBytes("late", late).WriteBytes("early", early) |
| 588 | // Over the in-memory threshold, so that the streaming path decides too. | 576 | // Over the in-memory threshold, so that the streaming path decides too. | ||
| 589 | - | f.writeBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)). | 577 | + | f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)). |
| 590 | - | writeBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...)) | 578 | + | WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...)) |
| 591 | - | f.commit("sniff") | 579 | + | f.Commit("sniff") |
| 592 | 580 | ||||
| 593 | for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} { | 581 | for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} { | ||
| 594 | - | info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p)) | 582 | + | info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p)) |
| 595 | require.NoError(t, err, p) | 583 | require.NoError(t, err, p) | ||
| 596 | require.NoError(t, rc.Close()) | 584 | require.NoError(t, rc.Close()) | ||
| 597 | assert.Equal(t, binary, info.Binary, p) | 585 | assert.Equal(t, binary, info.Binary, p) | ||
| 598 | } | 586 | } | ||
| 599 | } | 587 | } | ||
| 600 | 588 | ||||
| 601 | - | func TestOpenBlobLargeThroughStream(t *testing.T) { | 589 | + | func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) { |
| 602 | ctx := context.Background() | 590 | ctx := context.Background() | ||
| 603 | - | g, data := newReader(t, nil) | 591 | + | g, data := mk(t) |
| 604 | - | f := newFixtureRepo(t, data, "large") | 592 | + | f := NewFixture(t, data, "large") |
| 605 | - | content := randomBytes(1<<20+17, 7) | 593 | + | content := RandomBytes(1<<20+17, 7) |
| 606 | - | f.writeBytes("big.bin", content).commit("big") | 594 | + | f.WriteBytes("big.bin", content).Commit("big") |
| 607 | 595 | ||||
| 608 | - | info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big.bin")) | 596 | + | info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, "big.bin")) |
| 609 | require.NoError(t, err) | 597 | require.NoError(t, err) | ||
| 610 | assert.Equal(t, int64(len(content)), info.Size) | 598 | assert.Equal(t, int64(len(content)), info.Size) | ||
| 611 | - | assert.Equal(t, 1, g.BlobStreams()) | |||
| 612 | got, err := io.ReadAll(rc) | 599 | got, err := io.ReadAll(rc) | ||
| 613 | require.NoError(t, err) | 600 | require.NoError(t, err) | ||
| 614 | assert.Equal(t, content, got) | 601 | assert.Equal(t, content, got) | ||
| 615 | require.NoError(t, rc.Close()) | 602 | require.NoError(t, rc.Close()) | ||
| 616 | require.NoError(t, rc.Close(), "Close is idempotent") | 603 | require.NoError(t, rc.Close(), "Close is idempotent") | ||
| 617 | - | assert.Equal(t, 0, g.BlobStreams()) | |||
| 618 | } | 604 | } | ||
| 619 | 605 | ||||
| 620 | - | func TestCancelledContext(t *testing.T) { | 606 | + | func testCancelledContext(t *testing.T, mk Maker) { |
| 621 | - | g, data := newReader(t, nil) | 607 | + | g, data := mk(t) |
| 622 | - | h := newHistory(t, data) | 608 | + | h := NewHistory(t, data) |
| 623 | 609 | ||||
| 624 | ctx, cancel := context.WithCancel(context.Background()) | 610 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 625 | cancel() | 611 | cancel() | ||
| 626 | - | _, err := g.ListRefs(ctx, h.name) | 612 | + | _, err := g.ListRefs(ctx, h.Name) |
| 627 | assert.ErrorIs(t, err, context.Canceled) | 613 | assert.ErrorIs(t, err, context.Canceled) | ||
| 628 | - | _, err = g.ResolveRevision(ctx, h.name, domain.Revision{}) | 614 | + | _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{}) |
| 629 | assert.ErrorIs(t, err, context.Canceled) | 615 | assert.ErrorIs(t, err, context.Canceled) | ||
| 630 | - | _, err = g.GetCommit(ctx, h.name, domain.Revision{}, domain.DefaultDiffLimits()) | 616 | + | _, err = g.GetCommit(ctx, h.Name, domain.Revision{}, domain.DefaultDiffLimits()) |
| 631 | assert.ErrorIs(t, err, context.Canceled) | 617 | assert.ErrorIs(t, err, context.Canceled) | ||
| 632 | - | _, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 5}) | 618 | + | _, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5}) |
| 633 | assert.ErrorIs(t, err, context.Canceled) | 619 | assert.ErrorIs(t, err, context.Canceled) | ||
| 634 | 620 | ||||
| 635 | // The adapter still works with a live context. | 621 | // The adapter still works with a live context. | ||
| 636 | - | _, err = g.ResolveRevision(context.Background(), h.name, domain.Revision{}) | 622 | + | _, err = g.ResolveRevision(context.Background(), h.Name, domain.Revision{}) |
| 637 | assert.NoError(t, err) | 623 | assert.NoError(t, err) | ||
| 638 | } | 624 | } | ||
| 639 | 625 | ||||
| 640 | - | func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | 626 | + | func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) { |
| 641 | ctx := context.Background() | 627 | ctx := context.Background() | ||
| 642 | - | g, data := newReader(t, nil) | 628 | + | g, data := mk(t) |
| 643 | - | h := newHistory(t, data) | 629 | + | h := NewHistory(t, data) |
| 644 | 630 | ||||
| 645 | // Revisions that look like options or git revision syntax are rejected by | 631 | // Revisions that look like options or git revision syntax are rejected by | ||
| 646 | // the domain; ones that pass it are plain names for the reader. | 632 | // the domain; ones that pass it are plain names for the reader. | ||
| @@ -648,9 +634,9 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | |||||
| 648 | _, err := domain.ParseRevision(s) | 634 | _, err := domain.ParseRevision(s) | ||
| 649 | assert.ErrorIs(t, err, domain.ErrInvalidRevision, s) | 635 | assert.ErrorIs(t, err, domain.ErrInvalidRevision, s) | ||
| 650 | } | 636 | } | ||
| 651 | - | _, err := g.ResolveRevision(ctx, h.name, rev(t, "HEAD.x")) | 637 | + | _, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x")) |
| 652 | assert.ErrorIs(t, err, domain.ErrNotFound) | 638 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 653 | - | _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, ":(top)d"), false) | 639 | + | _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, ":(top)d"), false) |
| 654 | assert.ErrorIs(t, err, domain.ErrNotFound) | 640 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 655 | var pe *domain.InvalidNameError | 641 | var pe *domain.InvalidNameError | ||
| 656 | assert.False(t, errors.As(err, &pe)) | 642 | assert.False(t, errors.As(err, &pe)) | ||
| @@ -658,60 +644,60 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | |||||
| 658 | 644 | ||||
| 659 | // A full object id is an object id before it is a ref: a branch or a tag | 645 | // A full object id is an object id before it is a ref: a branch or a tag | ||
| 660 | // named like a commit must not redirect a link to that commit. | 646 | // named like a commit must not redirect a link to that commit. | ||
| 661 | - | func TestResolveRevisionObjectIDBeatsRefs(t *testing.T) { | 647 | + | func testResolveRevisionObjectIDBeatsRefs(t *testing.T, mk Maker) { |
| 662 | ctx := context.Background() | 648 | ctx := context.Background() | ||
| 663 | - | g, data := newReader(t, nil) | 649 | + | g, data := mk(t) |
| 664 | - | h := newHistory(t, data) | 650 | + | h := NewHistory(t, data) |
| 665 | 651 | ||||
| 666 | // Git itself warns about these refs and prefers the object id. | 652 | // Git itself warns about these refs and prefers the object id. | ||
| 667 | - | h.git("-c", "advice.objectNameWarning=false", "branch", h.c1, h.c3) | 653 | + | h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, h.C3) |
| 668 | - | h.git("-c", "advice.objectNameWarning=false", "tag", h.c2, h.c3) | 654 | + | h.Git("-c", "advice.objectNameWarning=false", "tag", h.C2, h.C3) |
| 669 | 655 | ||||
| 670 | - | got, err := g.ResolveRevision(ctx, h.name, rev(t, h.c1)) | 656 | + | got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1)) |
| 671 | require.NoError(t, err) | 657 | require.NoError(t, err) | ||
| 672 | - | assert.Equal(t, h.c1, got.CommitID.String(), "the commit, not the branch of the same name") | 658 | + | assert.Equal(t, h.C1, got.CommitID.String(), "the commit, not the branch of the same name") |
| 673 | assert.Empty(t, got.Ref) | 659 | assert.Empty(t, got.Ref) | ||
| 674 | 660 | ||||
| 675 | - | got, err = g.ResolveRevision(ctx, h.name, rev(t, h.c2)) | 661 | + | got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2)) |
| 676 | require.NoError(t, err) | 662 | require.NoError(t, err) | ||
| 677 | - | assert.Equal(t, h.c2, got.CommitID.String(), "the commit, not the tag of the same name") | 663 | + | assert.Equal(t, h.C2, got.CommitID.String(), "the commit, not the tag of the same name") |
| 678 | 664 | ||||
| 679 | // An abbreviation is still a name first, as before. | 665 | // An abbreviation is still a name first, as before. | ||
| 680 | - | h.git("branch", "abc1234", h.c3) | 666 | + | h.Git("branch", "abc1234", h.C3) |
| 681 | - | if !strings.HasPrefix(h.c1, "abc1234") { | 667 | + | if !strings.HasPrefix(h.C1, "abc1234") { |
| 682 | - | got, err = g.ResolveRevision(ctx, h.name, rev(t, "abc1234")) | 668 | + | got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234")) |
| 683 | require.NoError(t, err) | 669 | require.NoError(t, err) | ||
| 684 | - | assert.Equal(t, h.c3, got.CommitID.String()) | 670 | + | assert.Equal(t, h.C3, got.CommitID.String()) |
| 685 | assert.Equal(t, "refs/heads/abc1234", got.Ref) | 671 | assert.Equal(t, "refs/heads/abc1234", got.Ref) | ||
| 686 | } | 672 | } | ||
| 687 | 673 | ||||
| 688 | // The ref is used when no object has that id. | 674 | // The ref is used when no object has that id. | ||
| 689 | ghost := strings.Repeat("ab", 20) | 675 | ghost := strings.Repeat("ab", 20) | ||
| 690 | - | h.git("-c", "advice.objectNameWarning=false", "branch", ghost, h.c3) | 676 | + | h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3) |
| 691 | - | got, err = g.ResolveRevision(ctx, h.name, rev(t, ghost)) | 677 | + | got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost)) |
| 692 | require.NoError(t, err) | 678 | require.NoError(t, err) | ||
| 693 | - | assert.Equal(t, h.c3, got.CommitID.String()) | 679 | + | assert.Equal(t, h.C3, got.CommitID.String()) |
| 694 | assert.Equal(t, "refs/heads/"+ghost, got.Ref) | 680 | assert.Equal(t, "refs/heads/"+ghost, got.Ref) | ||
| 695 | } | 681 | } | ||
| 696 | 682 | ||||
| 697 | - | func TestGetTreeIsBounded(t *testing.T) { | 683 | + | func testGetTreeIsBounded(t *testing.T, mk Maker) { |
| 698 | ctx := context.Background() | 684 | ctx := context.Background() | ||
| 699 | - | g, data := newReader(t, nil) | 685 | + | g, data := mk(t) |
| 700 | - | f := newFixtureRepo(t, data, "wide") | 686 | + | f := NewFixture(t, data, "wide") |
| 701 | n := domain.MaxTreeEntries + 7 | 687 | n := domain.MaxTreeEntries + 7 | ||
| 702 | for i := range n { | 688 | for i := range n { | ||
| 703 | - | f.write(fmt.Sprintf("d/f%05d", i), "x") | 689 | + | f.Write(fmt.Sprintf("d/f%05d", i), "x") |
| 704 | } | 690 | } | ||
| 705 | - | f.commit("wide") | 691 | + | f.Commit("wide") |
| 706 | 692 | ||||
| 707 | - | tree, err := g.GetTree(ctx, f.name, domain.Revision{}, tpath(t, "d"), false) | 693 | + | tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, TreePath(t, "d"), false) |
| 708 | require.NoError(t, err) | 694 | require.NoError(t, err) | ||
| 709 | assert.True(t, tree.Truncated) | 695 | assert.True(t, tree.Truncated) | ||
| 710 | require.Len(t, tree.Entries, domain.MaxTreeEntries) | 696 | require.Len(t, tree.Entries, domain.MaxTreeEntries) | ||
| 711 | assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order") | 697 | assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order") | ||
| 712 | assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name) | 698 | assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name) | ||
| 713 | 699 | ||||
| 714 | - | small, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false) | 700 | + | small, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false) |
| 715 | require.NoError(t, err) | 701 | require.NoError(t, err) | ||
| 716 | assert.False(t, small.Truncated) | 702 | assert.False(t, small.Truncated) | ||
| 717 | } | 703 | } | ||
A internal/readertest/fixture.go +170 -0
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package readertest is the shared test kit of the RepoReader adapters: Git | |||
| 4 | + | // fixtures built with the git binary, the conformance table every adapter must | |||
| 5 | + | // pass, and the comparison of one adapter with another. | |||
| 6 | + | package readertest | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "fmt" | |||
| 10 | + | "math/rand/v2" | |||
| 11 | + | "os" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "strings" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ||||
| 20 | + | "bnema.dev/ermite/internal/domain" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Fixture builds a repository with real git: a bare repository in the | |||
| 24 | + | // data directory, and a work tree beside it that the helpers edit and commit. | |||
| 25 | + | type Fixture struct { | |||
| 26 | + | T testing.TB | |||
| 27 | + | Name domain.RepoName | |||
| 28 | + | Bare string | |||
| 29 | + | Work string | |||
| 30 | + | When time.Time | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewFixture creates an empty bare repository repos/<name>.git whose HEAD | |||
| 34 | + | // is refs/heads/main. | |||
| 35 | + | func NewFixture(t testing.TB, data, repo string) *Fixture { | |||
| 36 | + | t.Helper() | |||
| 37 | + | f := &Fixture{ | |||
| 38 | + | T: t, | |||
| 39 | + | Name: RepoName(t, repo), | |||
| 40 | + | Bare: filepath.Join(data, "repos", repo+".git"), | |||
| 41 | + | Work: t.TempDir(), | |||
| 42 | + | When: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), | |||
| 43 | + | } | |||
| 44 | + | out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.Bare).CombinedOutput() | |||
| 45 | + | require.NoError(t, err, string(out)) | |||
| 46 | + | f.Git("config", "core.compression", "0") | |||
| 47 | + | f.Git("config", "core.autocrlf", "false") | |||
| 48 | + | return f | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Git runs git against the bare repository and the work tree. | |||
| 52 | + | func (f *Fixture) Git(args ...string) string { | |||
| 53 | + | f.T.Helper() | |||
| 54 | + | cmd := exec.Command("git", args...) | |||
| 55 | + | cmd.Dir = f.Work | |||
| 56 | + | cmd.Env = []string{ | |||
| 57 | + | "PATH=" + os.Getenv("PATH"), | |||
| 58 | + | "HOME=" + f.Work, | |||
| 59 | + | "GIT_CONFIG_NOSYSTEM=1", | |||
| 60 | + | "GIT_CONFIG_GLOBAL=/dev/null", | |||
| 61 | + | "GIT_DIR=" + f.Bare, | |||
| 62 | + | "GIT_WORK_TREE=" + f.Work, | |||
| 63 | + | "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org", | |||
| 64 | + | "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org", | |||
| 65 | + | "GIT_AUTHOR_DATE=" + f.When.Format(time.RFC3339), | |||
| 66 | + | "GIT_COMMITTER_DATE=" + f.When.Format(time.RFC3339), | |||
| 67 | + | } | |||
| 68 | + | out, err := cmd.CombinedOutput() | |||
| 69 | + | require.NoError(f.T, err, "git %s: %s", strings.Join(args, " "), out) | |||
| 70 | + | return strings.TrimSpace(string(out)) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Path returns the path of a file of the work tree. | |||
| 74 | + | func (f *Fixture) Path(p string) string { return filepath.Join(f.Work, filepath.FromSlash(p)) } | |||
| 75 | + | ||||
| 76 | + | // Write creates or replaces a file with the content. | |||
| 77 | + | func (f *Fixture) Write(p, content string) *Fixture { | |||
| 78 | + | f.T.Helper() | |||
| 79 | + | require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(p)), 0o755)) | |||
| 80 | + | require.NoError(f.T, os.WriteFile(f.Path(p), []byte(content), 0o644)) | |||
| 81 | + | return f | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // WriteBytes is Write for binary content. | |||
| 85 | + | func (f *Fixture) WriteBytes(p string, content []byte) *Fixture { | |||
| 86 | + | f.T.Helper() | |||
| 87 | + | require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(p)), 0o755)) | |||
| 88 | + | require.NoError(f.T, os.WriteFile(f.Path(p), content, 0o644)) | |||
| 89 | + | return f | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Lines writes n lines "<prefix> <i>" to the file. | |||
| 93 | + | func (f *Fixture) Lines(p, prefix string, n int) *Fixture { | |||
| 94 | + | var b strings.Builder | |||
| 95 | + | for i := range n { | |||
| 96 | + | fmt.Fprintf(&b, "%s %d\n", prefix, i) | |||
| 97 | + | } | |||
| 98 | + | return f.Write(p, b.String()) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (f *Fixture) Remove(p string) *Fixture { | |||
| 102 | + | f.T.Helper() | |||
| 103 | + | require.NoError(f.T, os.Remove(f.Path(p))) | |||
| 104 | + | return f | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (f *Fixture) Chmod(p string, mode os.FileMode) *Fixture { | |||
| 108 | + | f.T.Helper() | |||
| 109 | + | require.NoError(f.T, os.Chmod(f.Path(p), mode)) | |||
| 110 | + | return f | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (f *Fixture) Symlink(target, p string) *Fixture { | |||
| 114 | + | f.T.Helper() | |||
| 115 | + | require.NoError(f.T, os.Symlink(target, f.Path(p))) | |||
| 116 | + | return f | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (f *Fixture) Mv(from, to string) *Fixture { | |||
| 120 | + | f.T.Helper() | |||
| 121 | + | require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(to)), 0o755)) | |||
| 122 | + | require.NoError(f.T, os.Rename(f.Path(from), f.Path(to))) | |||
| 123 | + | return f | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // Commit stages everything and commits with the next timestamp; it returns | |||
| 127 | + | // the commit id. | |||
| 128 | + | func (f *Fixture) Commit(msg string) string { | |||
| 129 | + | f.T.Helper() | |||
| 130 | + | f.When = f.When.Add(time.Minute) | |||
| 131 | + | f.Git("add", "-A") | |||
| 132 | + | f.Git("commit", "--quiet", "--allow-empty", "-m", msg) | |||
| 133 | + | return f.Git("rev-parse", "HEAD") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // CheckoutNew creates a branch from the current HEAD and switches to it. | |||
| 137 | + | func (f *Fixture) CheckoutNew(branch string) { | |||
| 138 | + | f.T.Helper() | |||
| 139 | + | f.Git("checkout", "--quiet", "-b", branch) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (f *Fixture) Checkout(branch string) { f.T.Helper(); f.Git("checkout", "--quiet", branch) } | |||
| 143 | + | ||||
| 144 | + | // Merge merges the branch with a merge commit. | |||
| 145 | + | func (f *Fixture) Merge(branch string) string { | |||
| 146 | + | f.T.Helper() | |||
| 147 | + | f.When = f.When.Add(time.Minute) | |||
| 148 | + | f.Git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch) | |||
| 149 | + | return f.Git("rev-parse", "HEAD") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // RandomBytes returns n deterministic incompressible bytes. | |||
| 153 | + | func RandomBytes(n int, seed uint64) []byte { | |||
| 154 | + | var s [32]byte | |||
| 155 | + | for i := range 8 { | |||
| 156 | + | s[i] = byte(seed >> (8 * i)) | |||
| 157 | + | } | |||
| 158 | + | r := rand.NewChaCha8(s) | |||
| 159 | + | b := make([]byte, n) | |||
| 160 | + | _, _ = r.Read(b) | |||
| 161 | + | return b | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // RepoName parses a repository name. | |||
| 165 | + | func RepoName(t testing.TB, s string) domain.RepoName { | |||
| 166 | + | t.Helper() | |||
| 167 | + | n, err := domain.ParseRepoName(s) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | return n | |||
| 170 | + | } | |||
A internal/readertest/fixtures.go +263 -0
| @@ -0,0 +1,263 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package readertest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Case is a repository the readers are compared on. | |||
| 15 | + | type Case struct { | |||
| 16 | + | Name string | |||
| 17 | + | Build func(t *testing.T, data string) *Fixture | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Maintain runs what ermite runs after a push: a split commit-graph with | |||
| 21 | + | // changed-path Bloom filters. | |||
| 22 | + | func (f *Fixture) Maintain() { | |||
| 23 | + | f.T.Helper() | |||
| 24 | + | f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split") | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Cases are the fixtures of the comparison: everything the gitcli tests use, | |||
| 28 | + | // plus merges, tags, deep paths, a large blob, an unborn HEAD, repositories | |||
| 29 | + | // with and without commit-graph (Bloom filters v1 and v2), and one whose | |||
| 30 | + | // graph is stale because commits were pushed after the last maintenance. | |||
| 31 | + | func Cases() []Case { | |||
| 32 | + | return []Case{ | |||
| 33 | + | {"history", func(t *testing.T, data string) *Fixture { return NewHistory(t, data).Fixture }}, | |||
| 34 | + | {"history with commit-graph v2", func(t *testing.T, data string) *Fixture { | |||
| 35 | + | h := NewHistory(t, data) | |||
| 36 | + | h.Git("config", "commitGraph.changedPathsVersion", "2") | |||
| 37 | + | h.Maintain() | |||
| 38 | + | return h.Fixture | |||
| 39 | + | }}, | |||
| 40 | + | {"history with commit-graph v1", func(t *testing.T, data string) *Fixture { | |||
| 41 | + | h := NewHistory(t, data) | |||
| 42 | + | h.Git("config", "commitGraph.changedPathsVersion", "1") | |||
| 43 | + | h.Maintain() | |||
| 44 | + | return h.Fixture | |||
| 45 | + | }}, | |||
| 46 | + | {"history packed", func(t *testing.T, data string) *Fixture { | |||
| 47 | + | h := NewHistory(t, data) | |||
| 48 | + | h.Git("repack", "-adf", "--depth=50", "--window=250") | |||
| 49 | + | h.Git("pack-refs", "--all") | |||
| 50 | + | h.Maintain() | |||
| 51 | + | return h.Fixture | |||
| 52 | + | }}, | |||
| 53 | + | {"unborn HEAD", func(t *testing.T, data string) *Fixture { return NewFixture(t, data, "empty") }}, | |||
| 54 | + | {"stale commit-graph", buildStale}, | |||
| 55 | + | {"merges", buildMerges}, | |||
| 56 | + | {"deep paths", buildDeep}, | |||
| 57 | + | {"large blobs", buildLarge}, | |||
| 58 | + | {"names and messages", buildNames}, | |||
| 59 | + | {"refs", buildRefs}, | |||
| 60 | + | {"submodule", buildSubmodule}, | |||
| 61 | + | {"wide directory", buildWide}, | |||
| 62 | + | {"detached HEAD", func(t *testing.T, data string) *Fixture { | |||
| 63 | + | h := NewHistory(t, data) | |||
| 64 | + | h.Git("update-ref", "--no-deref", "HEAD", h.C2) | |||
| 65 | + | return h.Fixture | |||
| 66 | + | }}, | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // buildStale writes the graph, then adds commits and refs that it does not | |||
| 71 | + | // know: a push followed by maintenance that has not run yet. | |||
| 72 | + | func buildStale(t *testing.T, data string) *Fixture { | |||
| 73 | + | f := NewFixture(t, data, "stale") | |||
| 74 | + | f.Git("config", "commitGraph.changedPathsVersion", "2") | |||
| 75 | + | for i := range 12 { | |||
| 76 | + | f.Write(fmt.Sprintf("dir%d/file.txt", i%3), fmt.Sprintf("v%d\n", i)).Write("root.txt", fmt.Sprintf("r%d\n", i/4)) | |||
| 77 | + | f.Commit(fmt.Sprintf("commit %d", i)) | |||
| 78 | + | } | |||
| 79 | + | f.Maintain() | |||
| 80 | + | f.Git("config", "commitGraph.changedPathsVersion", "1") | |||
| 81 | + | f.CheckoutNew("late") | |||
| 82 | + | for i := range 4 { | |||
| 83 | + | f.Write("dir1/file.txt", fmt.Sprintf("late%d\n", i)).Write(fmt.Sprintf("late%d.txt", i), "x\n") | |||
| 84 | + | f.Commit(fmt.Sprintf("late %d", i)) | |||
| 85 | + | } | |||
| 86 | + | f.Checkout("main") | |||
| 87 | + | f.Write("dir0/new.txt", "n\n") | |||
| 88 | + | f.Commit("after maintenance") | |||
| 89 | + | f.Merge("late") | |||
| 90 | + | return f | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // buildMerges has criss-cross merges, an octopus merge, a merge that takes | |||
| 94 | + | // one side of a file and one that is TREESAME to a parent, so that the path | |||
| 95 | + | // history simplification matters. | |||
| 96 | + | func buildMerges(t *testing.T, data string) *Fixture { | |||
| 97 | + | f := NewFixture(t, data, "merges") | |||
| 98 | + | f.Write("shared.txt", "base\n").Write("a/one.txt", "1\n").Write("b/one.txt", "1\n") | |||
| 99 | + | f.Commit("base") | |||
| 100 | + | f.CheckoutNew("left") | |||
| 101 | + | f.Write("a/one.txt", "left\n") | |||
| 102 | + | f.Commit("left a") | |||
| 103 | + | f.Checkout("main") | |||
| 104 | + | f.CheckoutNew("right") | |||
| 105 | + | f.Write("b/one.txt", "right\n").Write("shared.txt", "right\n") | |||
| 106 | + | f.Commit("right b") | |||
| 107 | + | f.Checkout("main") | |||
| 108 | + | f.Write("main.txt", "m\n") | |||
| 109 | + | f.Commit("main") | |||
| 110 | + | f.Merge("left") | |||
| 111 | + | f.Merge("right") | |||
| 112 | + | f.CheckoutNew("x1") | |||
| 113 | + | f.Write("x.txt", "x1\n") | |||
| 114 | + | f.Commit("x1") | |||
| 115 | + | f.Checkout("main") | |||
| 116 | + | f.CheckoutNew("x2") | |||
| 117 | + | f.Write("y.txt", "y\n") | |||
| 118 | + | f.Commit("x2") | |||
| 119 | + | f.Checkout("main") | |||
| 120 | + | f.Write("main.txt", "m2\n") | |||
| 121 | + | f.Commit("main again") | |||
| 122 | + | f.When = f.When.Add(time.Minute) | |||
| 123 | + | f.Git("merge", "--quiet", "--no-ff", "-m", "octopus", "x1", "x2") | |||
| 124 | + | // A merge whose result is the same as its first parent for shared.txt. | |||
| 125 | + | f.CheckoutNew("same") | |||
| 126 | + | f.Write("shared.txt", "same\n") | |||
| 127 | + | f.Commit("same 1") | |||
| 128 | + | f.Checkout("main") | |||
| 129 | + | f.Git("merge", "--quiet", "--no-ff", "-s", "ours", "-m", "ours merge", "same") | |||
| 130 | + | f.Write("tail.txt", "t\n") | |||
| 131 | + | f.Commit("tail") | |||
| 132 | + | f.Maintain() | |||
| 133 | + | return f | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func buildDeep(t *testing.T, data string) *Fixture { | |||
| 137 | + | f := NewFixture(t, data, "deep") | |||
| 138 | + | deep := strings.Repeat("d/", 30) + "leaf" | |||
| 139 | + | f.Write(deep+"/file.txt", "deep\n").Write("top.txt", "top\n") | |||
| 140 | + | f.Commit("deep") | |||
| 141 | + | f.Write(deep+"/file.txt", "deeper\n") | |||
| 142 | + | f.Commit("change deep") | |||
| 143 | + | f.Write("top.txt", "top2\n") | |||
| 144 | + | f.Commit("change top") | |||
| 145 | + | f.Write("héllo/ünï.txt", "utf8\n").Write("sp ace/a b.txt", "sp\n") | |||
| 146 | + | f.Commit("odd names") | |||
| 147 | + | f.Maintain() | |||
| 148 | + | return f | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // buildLarge has blobs on both sides of the in-memory threshold of gitcli | |||
| 152 | + | // (256 KiB), loose, packed as is and packed as deltas. | |||
| 153 | + | func buildLarge(t *testing.T, data string) *Fixture { | |||
| 154 | + | f := NewFixture(t, data, "large") | |||
| 155 | + | big := RandomBytes(12<<20, 9) | |||
| 156 | + | f.WriteBytes("big.bin", big).WriteBytes("mid.bin", RandomBytes(300<<10, 2)). | |||
| 157 | + | Write("small.txt", "s\n") | |||
| 158 | + | var text strings.Builder | |||
| 159 | + | for i := range 20000 { | |||
| 160 | + | fmt.Fprintf(&text, "line %d of a text that deltifies well\n", i) | |||
| 161 | + | } | |||
| 162 | + | f.Write("text.txt", text.String()) | |||
| 163 | + | f.Commit("big") | |||
| 164 | + | f.Write("text.txt", text.String()+"one more line\n") | |||
| 165 | + | f.Commit("big text, second version") | |||
| 166 | + | f.Git("repack", "-adf", "--depth=50", "--window=250") | |||
| 167 | + | f.Write("loose.bin", string(RandomBytes(400<<10, 4))) | |||
| 168 | + | f.Commit("loose") | |||
| 169 | + | f.Maintain() | |||
| 170 | + | return f | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // buildNames has messages and names git must convert or keep: no message, | |||
| 174 | + | // non-UTF-8, an encoding header, trailing blank lines, long summaries, CRLF. | |||
| 175 | + | func buildNames(t *testing.T, data string) *Fixture { | |||
| 176 | + | f := NewFixture(t, data, "names") | |||
| 177 | + | f.Write("a.txt", "a\n") | |||
| 178 | + | f.When = f.When.Add(time.Minute) | |||
| 179 | + | f.Git("add", "-A") | |||
| 180 | + | f.Git("commit", "--quiet", "--allow-empty-message", "-m", "") | |||
| 181 | + | f.Write("a.txt", "b\n") | |||
| 182 | + | f.Commit("subject\n\n\n\nbody\n\n\n") | |||
| 183 | + | f.Write("a.txt", "c\n") | |||
| 184 | + | f.Commit("caf\xe9 latin-1 message") | |||
| 185 | + | f.Write("a.txt", "d\n") | |||
| 186 | + | f.Commit("crlf\r\nsecond\r\n") | |||
| 187 | + | f.Write("a.txt", "e\n") | |||
| 188 | + | f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "add", "-A") | |||
| 189 | + | f.When = f.When.Add(time.Minute) | |||
| 190 | + | f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "enc: caf\xe9") | |||
| 191 | + | f.Write("a.txt", "f\n") | |||
| 192 | + | f.When = f.When.Add(time.Minute) | |||
| 193 | + | f.Git("add", "-A") | |||
| 194 | + | f.Git("-c", "user.name=Zoë <Ünï>", "commit", "--quiet", "--author=Zoë Ünï <zoe@example.org>", "-m", strings.Repeat("long ", 400)) | |||
| 195 | + | f.Write("héllo wörld.txt", "x\n").Write("tab\tname.txt", "t\n").Write("-dash", "d\n") | |||
| 196 | + | f.Commit("names") | |||
| 197 | + | f.Maintain() | |||
| 198 | + | return f | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // buildRefs has a branch and a tag with the same name, a branch named like a | |||
| 202 | + | // full commit id, a tag named like an abbreviation, nested tags, tags of | |||
| 203 | + | // non-commits, packed and loose refs, and refs outside heads and tags. | |||
| 204 | + | func buildRefs(t *testing.T, data string) *Fixture { | |||
| 205 | + | f := NewFixture(t, data, "refs") | |||
| 206 | + | f.Write("a.txt", "1\n") | |||
| 207 | + | c1 := f.Commit("one") | |||
| 208 | + | f.Write("a.txt", "2\n") | |||
| 209 | + | c2 := f.Commit("two") | |||
| 210 | + | f.Write("a.txt", "3\n") | |||
| 211 | + | c3 := f.Commit("three") | |||
| 212 | + | f.Git("branch", "same", c1) | |||
| 213 | + | f.Git("tag", "same", c2) | |||
| 214 | + | f.Git("tag", "-a", "-m", "annotated", "ann", c2) | |||
| 215 | + | f.Git("tag", "-a", "-m", "nested", "ann2", "ann") | |||
| 216 | + | f.Git("tag", "-a", "-m", "nested 3", "ann3", "ann2") | |||
| 217 | + | f.Git("tag", "-a", "-m", "tree", "tree-tag", c3+"^{tree}") | |||
| 218 | + | f.Git("tag", "-a", "-m", "blob", "blob-tag", c3+":a.txt") | |||
| 219 | + | f.Git("-c", "advice.objectNameWarning=false", "branch", c1, c3) | |||
| 220 | + | f.Git("-c", "advice.objectNameWarning=false", "tag", c2, c3) | |||
| 221 | + | ghost := strings.Repeat("cd", 20) | |||
| 222 | + | f.Git("-c", "advice.objectNameWarning=false", "branch", ghost, c3) | |||
| 223 | + | f.Git("-c", "advice.objectNameWarning=false", "tag", strings.Repeat("ef", 20), c1) | |||
| 224 | + | f.Git("branch", "dir/sub/branch", c2) | |||
| 225 | + | f.Git("update-ref", "refs/remotes/origin/main", c1) | |||
| 226 | + | f.Git("update-ref", "refs/notes/x", c1) | |||
| 227 | + | f.Git("pack-refs", "--all") | |||
| 228 | + | f.Git("branch", "loose-after-pack", c3) | |||
| 229 | + | f.Git("tag", "loose-tag", c1) | |||
| 230 | + | f.Git("branch", "-f", "same", c3) // loose overrides the packed one | |||
| 231 | + | return f | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // buildSubmodule has a gitlink entry, a symlink and an executable. | |||
| 235 | + | func buildSubmodule(t *testing.T, data string) *Fixture { | |||
| 236 | + | f := NewFixture(t, data, "submod") | |||
| 237 | + | f.Write("a.txt", "a\n").Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("a.txt", "link") | |||
| 238 | + | f.Commit("base") | |||
| 239 | + | f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod") | |||
| 240 | + | f.Git("commit", "--quiet", "-m", "add submodule") | |||
| 241 | + | f.Maintain() | |||
| 242 | + | return f | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // buildWide has a directory over MaxTreeEntries entries and one over | |||
| 246 | + | // MaxLastCommitEntries, with commits that touch some of them. | |||
| 247 | + | func buildWide(t *testing.T, data string) *Fixture { | |||
| 248 | + | f := NewFixture(t, data, "wide") | |||
| 249 | + | for i := range domain.MaxTreeEntries + 7 { | |||
| 250 | + | f.Write(fmt.Sprintf("d/f%05d", i), "x") | |||
| 251 | + | } | |||
| 252 | + | for i := range domain.MaxLastCommitEntries + 20 { | |||
| 253 | + | f.Write(fmt.Sprintf("m/e%03d", i), "x\n") | |||
| 254 | + | if i%7 == 0 { | |||
| 255 | + | f.Write(fmt.Sprintf("m/sub%03d/x", i), "x\n") | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | f.Commit("wide") | |||
| 259 | + | f.Write("m/e003", "changed\n").Write("m/e250", "changed\n").Write("d/f00010", "y") | |||
| 260 | + | f.Commit("touch some") | |||
| 261 | + | f.Maintain() | |||
| 262 | + | return f | |||
| 263 | + | } | |||
A internal/readertest/history.go +53 -0
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package readertest | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | // History is the repository of the read tests: three commits on main, a | |||
| 8 | + | // branch with a slash, a merge, lightweight and annotated tags. | |||
| 9 | + | type History struct { | |||
| 10 | + | *Fixture | |||
| 11 | + | C1, C2, C3, Side, Merge string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewHistory(t *testing.T, data string) *History { | |||
| 15 | + | t.Helper() | |||
| 16 | + | f := NewFixture(t, data, "hist") | |||
| 17 | + | h := &History{Fixture: f} | |||
| 18 | + | f.Write("a.txt", "one\ntwo\nthree\n"). | |||
| 19 | + | WriteBytes("bin", []byte{0, 1}). | |||
| 20 | + | Write("d/f", "hi\n"). | |||
| 21 | + | Write("d/sub/g", "deep\n"). | |||
| 22 | + | Write("we:ird*.txt", "x"). | |||
| 23 | + | Write("README.md", "# hello\n"). | |||
| 24 | + | Write("empty", "") | |||
| 25 | + | h.C1 = f.Commit("first\n\nbody of the first commit\n") | |||
| 26 | + | ||||
| 27 | + | f.Mv("a.txt", "b.txt").Write("b.txt", "one\ntwo\nthree\nfour\n"). | |||
| 28 | + | WriteBytes("bin", []byte{0, 2}). | |||
| 29 | + | Chmod("d/f", 0o755). | |||
| 30 | + | Write("n.txt", "new\nnonl"). | |||
| 31 | + | Remove("README.md") | |||
| 32 | + | h.C2 = f.Commit("second") | |||
| 33 | + | ||||
| 34 | + | f.Remove("n.txt").Symlink("b.txt", "n.txt") | |||
| 35 | + | h.C3 = f.Commit("third: type change") | |||
| 36 | + | ||||
| 37 | + | f.CheckoutNew("feature/x") | |||
| 38 | + | f.Write("side.txt", "s\n") | |||
| 39 | + | h.Side = f.Commit("side work") | |||
| 40 | + | f.Checkout("main") | |||
| 41 | + | f.Write("main.txt", "m\n") | |||
| 42 | + | f.Commit("main work") | |||
| 43 | + | h.Merge = f.Merge("feature/x") | |||
| 44 | + | ||||
| 45 | + | f.Git("tag", "v1", h.C1) | |||
| 46 | + | f.Git("tag", "-a", "-m", "release two", "v2", h.C2) | |||
| 47 | + | f.Git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2") | |||
| 48 | + | f.Git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.Merge+"^{tree}") | |||
| 49 | + | f.Git("branch", "release/1.0", h.C2) | |||
| 50 | + | f.Git("branch", "rel/1", h.C1) | |||
| 51 | + | f.Git("tag", "rel/1/x", h.C3) | |||
| 52 | + | return h | |||
| 53 | + | } | |||