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 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed | |
| 16 | + | bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0 | |
| 16 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 17 | 18 | connectrpc.com/connect v1.21.0 | |
| 18 | 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 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/readertest" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 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 | 61 | // showSections is git's own patch text of a commit against its first parent. | |
| 61 | - | func (f *fixtureRepo) showSections(id string) []string { | |
| 62 | - | f.t.Helper() | |
| 63 | - | out := f.git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id) | |
| 62 | + | func showSections(f *readertest.Fixture, id string) []string { | |
| 63 | + | f.T.Helper() | |
| 64 | + | out := f.Git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id) | |
| 64 | 65 | return gitSections(strings.ToValidUTF8(out, "\uFFFD")) | |
| 65 | 66 | } | |
| 66 | 67 | ||
| 67 | 68 | // showNumstat is git's own numstat of a commit against its first parent. | |
| 68 | - | func (f *fixtureRepo) showNumstat(id string) []string { | |
| 69 | - | f.t.Helper() | |
| 70 | - | out := f.git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id) | |
| 69 | + | func showNumstat(f *readertest.Fixture, id string) []string { | |
| 70 | + | f.T.Helper() | |
| 71 | + | out := f.Git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id) | |
| 71 | 72 | if out == "" { | |
| 72 | 73 | return nil | |
| 73 | 74 | } | |
| @@ -76,11 +77,11 @@ func (f *fixtureRepo) showNumstat(id string) []string { | |||
| 76 | 77 | ||
| 77 | 78 | // assertMatchesGitShow checks the structured diff of a commit against the | |
| 78 | 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 | 81 | t.Helper() | |
| 81 | - | sections := f.showSections(id) | |
| 82 | + | sections := showSections(f, id) | |
| 82 | 83 | var adds, dels int | |
| 83 | - | numstat := f.showNumstat(id) | |
| 84 | + | numstat := showNumstat(f, id) | |
| 84 | 85 | require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file") | |
| 85 | 86 | si := 0 | |
| 86 | 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 | 114 | func TestGetCommitMatchesGitShow(t *testing.T) { | |
| 114 | 115 | ctx := context.Background() | |
| 115 | 116 | g, data := newReader(t, nil) | |
| 116 | - | h := newHistory(t, data) | |
| 117 | + | h := readertest.NewHistory(t, data) | |
| 117 | 118 | ||
| 118 | 119 | for _, tt := range []struct{ name, id string }{ | |
| 119 | - | {"root commit: adds, binary, empty file", h.c1}, | |
| 120 | - | {"rename with change, binary, mode change, add, delete", h.c2}, | |
| 121 | - | {"type change", h.c3}, | |
| 122 | - | {"merge, against the first parent", h.merge}, | |
| 123 | - | {"side branch", h.side}, | |
| 120 | + | {"root commit: adds, binary, empty file", h.C1}, | |
| 121 | + | {"rename with change, binary, mode change, add, delete", h.C2}, | |
| 122 | + | {"type change", h.C3}, | |
| 123 | + | {"merge, against the first parent", h.Merge}, | |
| 124 | + | {"side branch", h.Side}, | |
| 124 | 125 | } { | |
| 125 | 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 | 128 | require.NoError(t, err) | |
| 128 | 129 | assert.Equal(t, tt.id, d.Commit.ID.String()) | |
| 129 | 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 | 136 | func TestGetCommitFileMetadata(t *testing.T) { | |
| 136 | 137 | ctx := context.Background() | |
| 137 | 138 | g, data := newReader(t, nil) | |
| 138 | - | h := newHistory(t, data) | |
| 139 | + | h := readertest.NewHistory(t, data) | |
| 139 | 140 | ||
| 140 | 141 | byPath := func(d domain.Diff) map[string]domain.FileDiff { | |
| 141 | 142 | m := map[string]domain.FileDiff{} | |
| @@ -144,11 +145,11 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 144 | 145 | } | |
| 145 | 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 | 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 | 153 | files := byPath(d.Diff) | |
| 153 | 154 | require.Len(t, files, 5) | |
| 154 | 155 | ||
| @@ -159,8 +160,8 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 159 | 160 | assert.Equal(t, 0, ren.Deletions) | |
| 160 | 161 | assert.Equal(t, uint32(0o100644), ren.OldMode) | |
| 161 | 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.c2+":b.txt"), ren.NewID) | |
| 163 | + | assert.Equal(t, id(h.C1+":a.txt"), ren.OldID) | |
| 164 | + | assert.Equal(t, id(h.C2+":b.txt"), ren.NewID) | |
| 164 | 165 | require.Len(t, ren.Hunks, 1) | |
| 165 | 166 | assert.Equal(t, domain.Hunk{ | |
| 166 | 167 | OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4, | |
| @@ -203,7 +204,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 203 | 204 | last := add.Hunks[0].Lines[1] | |
| 204 | 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 | 208 | require.NoError(t, err) | |
| 208 | 209 | require.Len(t, d.Diff.Files, 1) | |
| 209 | 210 | tc := d.Diff.Files[0] | |
| @@ -216,9 +217,9 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 216 | 217 | func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) { | |
| 217 | 218 | ctx := context.Background() | |
| 218 | 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 | 223 | require.NoError(t, err) | |
| 223 | 224 | require.Len(t, d.Commit.Parents, 2) | |
| 224 | 225 | require.Len(t, d.Diff.Files, 1) | |
| @@ -229,10 +230,10 @@ func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) { | |||
| 229 | 230 | func TestGetCommitEmptyCommit(t *testing.T) { | |
| 230 | 231 | ctx := context.Background() | |
| 231 | 232 | g, data := newReader(t, nil) | |
| 232 | - | f := newFixtureRepo(t, data, "empty-commit") | |
| 233 | - | id := f.commit("nothing") | |
| 233 | + | f := readertest.NewFixture(t, data, "empty-commit") | |
| 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 | 237 | require.NoError(t, err) | |
| 237 | 238 | assert.Equal(t, id, d.Commit.ID.String()) | |
| 238 | 239 | assert.Empty(t, d.Diff.Files) | |
| @@ -243,9 +244,9 @@ func TestGetCommitEmptyCommit(t *testing.T) { | |||
| 243 | 244 | func TestGetCommitErrors(t *testing.T) { | |
| 244 | 245 | ctx := context.Background() | |
| 245 | 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 | 250 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 250 | 251 | _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits()) | |
| 251 | 252 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| @@ -254,19 +255,19 @@ func TestGetCommitErrors(t *testing.T) { | |||
| 254 | 255 | func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) { | |
| 255 | 256 | ctx := context.Background() | |
| 256 | 257 | g, data := newReader(t, nil) | |
| 257 | - | f := newFixtureRepo(t, data, "hunks") | |
| 258 | + | f := readertest.NewFixture(t, data, "hunks") | |
| 258 | 259 | var src strings.Builder | |
| 259 | 260 | src.WriteString("package x\n\nfunc Alpha() {\n") | |
| 260 | 261 | for i := range 40 { | |
| 261 | 262 | fmt.Fprintf(&src, "\tline(%d)\n", i) | |
| 262 | 263 | } | |
| 263 | 264 | src.WriteString("}\n") | |
| 264 | - | f.write("x.go", src.String()).write("latin", "caf\xe9\nok\n") | |
| 265 | - | f.commit("v1") | |
| 266 | - | f.write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).write("latin", "caf\xe9\nOK\n") | |
| 267 | - | id := f.commit("v2") | |
| 265 | + | f.Write("x.go", src.String()).Write("latin", "caf\xe9\nok\n") | |
| 266 | + | f.Commit("v1") | |
| 267 | + | f.Write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).Write("latin", "caf\xe9\nOK\n") | |
| 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 | 271 | require.NoError(t, err) | |
| 271 | 272 | assertMatchesGitShow(t, f, id, d) | |
| 272 | 273 | var gofile, latin domain.FileDiff | |
| @@ -286,11 +287,11 @@ func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) { | |||
| 286 | 287 | } | |
| 287 | 288 | ||
| 288 | 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 | 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 | 297 | func TestDiffLimits(t *testing.T) { | |
| @@ -308,9 +309,9 @@ func TestDiffLimits(t *testing.T) { | |||
| 308 | 309 | } | |
| 309 | 310 | ||
| 310 | 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 | 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 | 315 | require.NoError(t, err) | |
| 315 | 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 | 330 | t.Run("context counts towards the file limit", func(t *testing.T) { | |
| 330 | - | f := newFixtureRepo(t, data, "limit-context") | |
| 331 | - | f.lines("big", "l", 3000).commit("v1") | |
| 331 | + | f := readertest.NewFixture(t, data, "limit-context") | |
| 332 | + | f.Lines("big", "l", 3000).Commit("v1") | |
| 332 | 333 | // Every 7th line changes: ~428 changed lines, but the hunks merge | |
| 333 | 334 | // into one that spans the file with its context. | |
| 334 | 335 | var b strings.Builder | |
| @@ -339,10 +340,10 @@ func TestDiffLimits(t *testing.T) { | |||
| 339 | 340 | fmt.Fprintf(&b, "l %d\n", i) | |
| 340 | 341 | } | |
| 341 | 342 | } | |
| 342 | - | f.write("big", b.String()) | |
| 343 | - | f.write("tiny", "x\n") | |
| 344 | - | id := f.commit("v2") | |
| 345 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | |
| 343 | + | f.Write("big", b.String()) | |
| 344 | + | f.Write("tiny", "x\n") | |
| 345 | + | id := f.Commit("v2") | |
| 346 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 346 | 347 | require.NoError(t, err) | |
| 347 | 348 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | |
| 348 | 349 | assert.True(t, big.Truncated) | |
| @@ -355,7 +356,7 @@ func TestDiffLimits(t *testing.T) { | |||
| 355 | 356 | }) | |
| 356 | 357 | ||
| 357 | 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 | 360 | files := map[string]int{} | |
| 360 | 361 | for i := range 11 { | |
| 361 | 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 | 365 | files["h-big"] = 1500 // does not fit | |
| 365 | 366 | files["i-small"] = 10 // fits | |
| 366 | 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 | 369 | require.NoError(t, err) | |
| 369 | 370 | require.Len(t, d.Diff.Files, 14) | |
| 370 | 371 | total := 0 | |
| @@ -387,12 +388,12 @@ func TestDiffLimits(t *testing.T) { | |||
| 387 | 388 | }) | |
| 388 | 389 | ||
| 389 | 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 | 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 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | |
| 395 | + | f.Commit("many") | |
| 396 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 396 | 397 | require.NoError(t, err) | |
| 397 | 398 | assert.Len(t, d.Diff.Files, 300) | |
| 398 | 399 | assert.True(t, d.Diff.Truncated) | |
| @@ -406,28 +407,28 @@ func TestDiffLimits(t *testing.T) { | |||
| 406 | 407 | }) | |
| 407 | 408 | ||
| 408 | 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 | 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 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | |
| 414 | + | f.Commit("300") | |
| 415 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 415 | 416 | require.NoError(t, err) | |
| 416 | 417 | assert.Len(t, d.Diff.Files, 300) | |
| 417 | 418 | assert.False(t, d.Diff.Truncated) | |
| 418 | 419 | }) | |
| 419 | 420 | ||
| 420 | 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 | 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 | 425 | require.NoError(t, err) | |
| 425 | 426 | require.Len(t, d.Diff.Files, 2) | |
| 426 | 427 | assert.False(t, d.Diff.Files[0].Truncated) | |
| 427 | 428 | assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left") | |
| 428 | 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 | 432 | require.NoError(t, err) | |
| 432 | 433 | require.Len(t, d.Diff.Files, 3) | |
| 433 | 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 | 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.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) | |
| 442 | + | f := readertest.NewFixture(t, data, "limit-line") | |
| 443 | + | f.Write("long", strings.Repeat("x", 200<<10)+"\n").Write("short", "ok\n").Commit("long") | |
| 444 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 444 | 445 | require.NoError(t, err) | |
| 445 | 446 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | |
| 446 | 447 | assert.True(t, long.Truncated) | |
| @@ -450,12 +451,12 @@ func TestDiffLimits(t *testing.T) { | |||
| 450 | 451 | }) | |
| 451 | 452 | ||
| 452 | 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.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.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 | - | d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults) | |
| 454 | + | f := readertest.NewFixture(t, data, "limit-mixed") | |
| 455 | + | f.Lines("a-big", "x", 2500).WriteBytes("b-bin", []byte{0, 1, 2}).Write("c-mode", "m\n").Write("d-text", "t\n") | |
| 456 | + | f.Commit("v1") | |
| 457 | + | f.Lines("a-big", "y", 2500).WriteBytes("b-bin", []byte{0, 3}).Chmod("c-mode", 0o755).Write("d-text", "t2\n") | |
| 458 | + | id := f.Commit("v2") | |
| 459 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 459 | 460 | require.NoError(t, err) | |
| 460 | 461 | assert.True(t, find(d.Diff, "a-big").Truncated) | |
| 461 | 462 | assert.True(t, find(d.Diff, "b-bin").Binary) | |
| @@ -463,7 +464,7 @@ func TestDiffLimits(t *testing.T) { | |||
| 463 | 464 | assert.False(t, find(d.Diff, "c-mode").Truncated) | |
| 464 | 465 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | |
| 465 | 466 | // What is kept matches git. | |
| 466 | - | sections := f.showSections(id) | |
| 467 | + | sections := showSections(f, id) | |
| 467 | 468 | require.Len(t, sections, 4) | |
| 468 | 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 | 473 | func TestGetCommitStopsGitEarly(t *testing.T) { | |
| 473 | 474 | ctx := context.Background() | |
| 474 | 475 | g, data := newReader(t, nil) | |
| 475 | - | f := newFixtureRepo(t, data, "early") | |
| 476 | - | f.lines("a", "x", 5).lines("z-huge", "y", 100000).commit("c") | |
| 476 | + | f := readertest.NewFixture(t, data, "early") | |
| 477 | + | f.Lines("a", "x", 5).Lines("z-huge", "y", 100000).Commit("c") | |
| 477 | 478 | ||
| 478 | 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 | 481 | require.NoError(t, err) | |
| 481 | 482 | require.Len(t, d.Diff.Files, 2) | |
| 482 | 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 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/readertest" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 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 | 24 | // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat. | |
| 24 | 25 | type mergeFixture struct { | |
| 25 | - | f *fixtureRepo | |
| 26 | + | f *readertest.Fixture | |
| 26 | 27 | a, b, c, d domain.ObjectID | |
| 27 | 28 | mainTip, featTip domain.ObjectID | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | func newMergeFixture(t *testing.T, data string) mergeFixture { | |
| 31 | 32 | t.Helper() | |
| 32 | - | f := newFixtureRepo(t, data, "proj") | |
| 33 | - | f.write("a.txt", "one\ntwo\nthree\n") | |
| 34 | - | a := f.commit("add a") | |
| 35 | - | f.write("b.txt", "bee\n") | |
| 36 | - | b := f.commit("add b") | |
| 37 | - | f.git("update-ref", "refs/heads/main", b) | |
| 38 | - | f.checkoutNew("feat") | |
| 39 | - | f.write("a.txt", "one\n2\nthree\n") | |
| 40 | - | c := f.commit("change a") | |
| 41 | - | f.write("c.txt", "see\n") | |
| 42 | - | d := f.commit("add c") | |
| 43 | - | f.git("update-ref", "refs/heads/feat", d) | |
| 33 | + | f := readertest.NewFixture(t, data, "proj") | |
| 34 | + | f.Write("a.txt", "one\ntwo\nthree\n") | |
| 35 | + | a := f.Commit("add a") | |
| 36 | + | f.Write("b.txt", "bee\n") | |
| 37 | + | b := f.Commit("add b") | |
| 38 | + | f.Git("update-ref", "refs/heads/main", b) | |
| 39 | + | f.CheckoutNew("feat") | |
| 40 | + | f.Write("a.txt", "one\n2\nthree\n") | |
| 41 | + | c := f.Commit("change a") | |
| 42 | + | f.Write("c.txt", "see\n") | |
| 43 | + | d := f.Commit("add c") | |
| 44 | + | f.Git("update-ref", "refs/heads/feat", d) | |
| 44 | 45 | return mergeFixture{ | |
| 45 | 46 | f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d), | |
| 46 | 47 | mainTip: oidOf(t, b), featTip: oidOf(t, d), | |
| @@ -53,27 +54,27 @@ func TestBranchHeadAndHasCommit(t *testing.T) { | |||
| 53 | 54 | m := newMergeFixture(t, data) | |
| 54 | 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 | 58 | require.NoError(t, err) | |
| 58 | 59 | assert.Equal(t, m.d, got) | |
| 59 | 60 | ||
| 60 | 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 | 63 | assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch) | |
| 63 | 64 | } | |
| 64 | 65 | _, err = g.BranchHead(ctx, name(t, "missing"), "main") | |
| 65 | 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 | 69 | require.NoError(t, err) | |
| 69 | 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 | 72 | require.NoError(t, err) | |
| 72 | 73 | assert.False(t, ok) | |
| 73 | 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 | 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 | 78 | require.NoError(t, err) | |
| 78 | 79 | assert.False(t, ok) | |
| 79 | 80 | } | |
| @@ -83,23 +84,23 @@ func TestMergeBaseAndAncestry(t *testing.T) { | |||
| 83 | 84 | ctx := context.Background() | |
| 84 | 85 | m := newMergeFixture(t, data) | |
| 85 | 86 | // A branch that diverged from a. | |
| 86 | - | m.f.git("checkout", "--quiet", "-b", "side", m.a.String()) | |
| 87 | - | m.f.write("s.txt", "side\n") | |
| 88 | - | side := oidOf(t, m.f.commit("side")) | |
| 89 | - | m.f.git("update-ref", "refs/heads/side", side.String()) | |
| 87 | + | m.f.Git("checkout", "--quiet", "-b", "side", m.a.String()) | |
| 88 | + | m.f.Write("s.txt", "side\n") | |
| 89 | + | side := oidOf(t, m.f.Commit("side")) | |
| 90 | + | m.f.Git("update-ref", "refs/heads/side", side.String()) | |
| 90 | 91 | // An unrelated root. | |
| 91 | - | m.f.git("checkout", "--quiet", "--orphan", "other") | |
| 92 | - | m.f.git("rm", "-rf", "--quiet", ".") | |
| 93 | - | m.f.write("o.txt", "other\n") | |
| 94 | - | other := oidOf(t, m.f.commit("other root")) | |
| 92 | + | m.f.Git("checkout", "--quiet", "--orphan", "other") | |
| 93 | + | m.f.Git("rm", "-rf", "--quiet", ".") | |
| 94 | + | m.f.Write("o.txt", "other\n") | |
| 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 | 98 | require.NoError(t, err) | |
| 98 | 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 | 101 | require.NoError(t, err) | |
| 101 | 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 | 104 | assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history") | |
| 104 | 105 | ||
| 105 | 106 | for _, tc := range []struct { | |
| @@ -112,11 +113,11 @@ func TestMergeBaseAndAncestry(t *testing.T) { | |||
| 112 | 113 | {side, m.featTip, false}, | |
| 113 | 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 | 117 | require.NoError(t, err) | |
| 117 | 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 | 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 | 126 | ctx := context.Background() | |
| 126 | 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 | 130 | require.NoError(t, err) | |
| 130 | 131 | assert.False(t, more) | |
| 131 | 132 | require.Len(t, commits, 2) | |
| @@ -133,21 +134,21 @@ func TestListRange(t *testing.T) { | |||
| 133 | 134 | assert.Equal(t, m.c, commits[1].ID) | |
| 134 | 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 | 138 | require.NoError(t, err) | |
| 138 | 139 | assert.True(t, more) | |
| 139 | 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 | 143 | require.NoError(t, err) | |
| 143 | 144 | assert.False(t, more) | |
| 144 | 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 | 148 | require.NoError(t, err) | |
| 148 | 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 | 152 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 152 | 153 | } | |
| 153 | 154 | ||
| @@ -156,7 +157,7 @@ func TestDiffCommits(t *testing.T) { | |||
| 156 | 157 | ctx := context.Background() | |
| 157 | 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 | 161 | require.NoError(t, err) | |
| 161 | 162 | assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats) | |
| 162 | 163 | require.Len(t, diff.Files, 2) | |
| @@ -168,7 +169,7 @@ func TestDiffCommits(t *testing.T) { | |||
| 168 | 169 | assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1)) | |
| 169 | 170 | ||
| 170 | 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 | 173 | require.NoError(t, err) | |
| 173 | 174 | assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats) | |
| 174 | 175 | } | |
| @@ -178,29 +179,29 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) { | |||
| 178 | 179 | ctx := context.Background() | |
| 179 | 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 | 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 | 186 | obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" + | |
| 186 | 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 | 189 | require.NoError(t, err) | |
| 189 | - | 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())) | |
| 190 | + | assert.Equal(t, "commit", m.f.Git("cat-file", "-t", 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 | 194 | assert.Error(t, err, "git checks the object") | |
| 194 | 195 | ||
| 195 | 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 | 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 | - | assert.Equal(t, id.String(), m.f.git("rev-parse", "refs/heads/main")) | |
| 201 | + | require.NoError(t, g.UpdateBranch(ctx, m.f.Name, "main", m.mainTip, id)) | |
| 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 | 205 | assert.Error(t, err, "only short branch names") | |
| 205 | 206 | err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip) | |
| 206 | 207 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
M internal/adapters/gitcli/pool_test.go +47 -46
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | ||
| 21 | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 22 | 22 | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/readertest" | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| 25 | 26 | // alive reports whether a process still exists. The adapter collects its | |
| @@ -43,17 +44,17 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||
| 43 | 44 | // The default idle timeout (60 s) never fires during the test: the hooks | |
| 44 | 45 | // do what the timers would, deterministically. | |
| 45 | 46 | g, data := newReader(t, nil) | |
| 46 | - | h := newHistory(t, data) | |
| 47 | + | h := readertest.NewHistory(t, data) | |
| 47 | 48 | ||
| 48 | 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 | 51 | require.NoError(t, err) | |
| 51 | 52 | pids := g.CatFilePIDs() | |
| 52 | 53 | require.Len(t, pids, 1) | |
| 53 | 54 | require.True(t, alive(pids[0])) | |
| 54 | 55 | ||
| 55 | 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 | 58 | require.NoError(t, err) | |
| 58 | 59 | } | |
| 59 | 60 | assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request") | |
| @@ -68,7 +69,7 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||
| 68 | 69 | requireGone(t, pids...) | |
| 69 | 70 | ||
| 70 | 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 | 73 | require.NoError(t, err) | |
| 73 | 74 | next := g.CatFilePIDs() | |
| 74 | 75 | require.Len(t, next, 1) | |
| @@ -78,8 +79,8 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) { | |||
| 78 | 79 | func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) { | |
| 79 | 80 | ctx := context.Background() | |
| 80 | 81 | g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 20 * time.Millisecond }) | |
| 81 | - | h := newHistory(t, data) | |
| 82 | - | _, err := g.ResolveRevision(ctx, h.name, domain.Revision{}) | |
| 82 | + | h := readertest.NewHistory(t, data) | |
| 83 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) | |
| 83 | 84 | require.NoError(t, err) | |
| 84 | 85 | pids := g.CatFilePIDs() | |
| 85 | 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 | 90 | func TestCloseStopsEveryProcess(t *testing.T) { | |
| 90 | 91 | ctx := context.Background() | |
| 91 | 92 | g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them | |
| 92 | - | h := newHistory(t, data) | |
| 93 | - | f2 := newFixtureRepo(t, data, "second") | |
| 94 | - | f2.write("x", "x").commit("x") | |
| 95 | - | big := newFixtureRepo(t, data, "bigone") | |
| 96 | - | big.writeBytes("big", randomBytes(600<<10, 1)).commit("big") | |
| 93 | + | h := readertest.NewHistory(t, data) | |
| 94 | + | f2 := readertest.NewFixture(t, data, "second") | |
| 95 | + | f2.Write("x", "x").Commit("x") | |
| 96 | + | big := readertest.NewFixture(t, data, "bigone") | |
| 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 | 100 | require.NoError(t, err) | |
| 100 | - | _, err = g.ResolveRevision(ctx, f2.name, domain.Revision{}) | |
| 101 | + | _, err = g.ResolveRevision(ctx, f2.Name, domain.Revision{}) | |
| 101 | 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 | 104 | require.NoError(t, err) | |
| 104 | 105 | _ = rc // left open on purpose: Close must stop its process too | |
| 105 | 106 | ||
| @@ -115,9 +116,9 @@ func TestCloseStopsEveryProcess(t *testing.T) { | |||
| 115 | 116 | requireGone(t, pids...) | |
| 116 | 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 | 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 | 122 | assert.ErrorIs(t, err, gitcli.ErrClosed) | |
| 122 | 123 | assert.NoError(t, g.Close(ctx), "Close is idempotent") | |
| 123 | 124 | _, err = io.Copy(io.Discard, rc) | |
| @@ -131,9 +132,9 @@ func TestPoolIsBounded(t *testing.T) { | |||
| 131 | 132 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 }) | |
| 132 | 133 | var names []domain.RepoName | |
| 133 | 134 | for i := range 6 { | |
| 134 | - | f := newFixtureRepo(t, data, fmt.Sprintf("r%d", i)) | |
| 135 | - | f.write("x", "x").commit("x") | |
| 136 | - | names = append(names, f.name) | |
| 135 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) | |
| 136 | + | f.Write("x", "x").Commit("x") | |
| 137 | + | names = append(names, f.Name) | |
| 137 | 138 | } | |
| 138 | 139 | var seen []int | |
| 139 | 140 | for _, n := range names { | |
| @@ -158,9 +159,9 @@ func TestPoolIsBounded(t *testing.T) { | |||
| 158 | 159 | func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |
| 159 | 160 | ctx := context.Background() | |
| 160 | 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 | 165 | require.NoError(t, err) | |
| 165 | 166 | old := g.CatFilePIDs() | |
| 166 | 167 | require.Len(t, old, 1) | |
| @@ -168,9 +169,9 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||
| 168 | 169 | requireGone(t, old[0]) | |
| 169 | 170 | ||
| 170 | 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 | 173 | require.NoError(t, err) | |
| 173 | - | assert.Equal(t, h.merge, got.CommitID.String()) | |
| 174 | + | assert.Equal(t, h.Merge, got.CommitID.String()) | |
| 174 | 175 | now := g.CatFilePIDs() | |
| 175 | 176 | require.Len(t, now, 1) | |
| 176 | 177 | assert.NotEqual(t, old, now) | |
| @@ -179,7 +180,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||
| 179 | 180 | func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |
| 180 | 181 | ctx := context.Background() | |
| 181 | 182 | g, data := newReader(t, nil) | |
| 182 | - | h := newHistory(t, data) | |
| 183 | + | h := readertest.NewHistory(t, data) | |
| 183 | 184 | ||
| 184 | 185 | var wg sync.WaitGroup | |
| 185 | 186 | errs := make(chan error, 24*10) | |
| @@ -188,13 +189,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||
| 188 | 189 | for j := range 10 { | |
| 189 | 190 | switch (i + j) % 4 { | |
| 190 | 191 | case 0: | |
| 191 | - | res, err := g.ResolveRevision(ctx, h.name, rev(t, "feature/x")) | |
| 192 | - | if err == nil && res.CommitID.String() != h.side { | |
| 192 | + | res, err := g.ResolveRevision(ctx, h.Name, readertest.Rev(t, "feature/x")) | |
| 193 | + | if err == nil && res.CommitID.String() != h.Side { | |
| 193 | 194 | err = fmt.Errorf("resolved %s", res.CommitID) | |
| 194 | 195 | } | |
| 195 | 196 | errs <- err | |
| 196 | 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 | 199 | if err == nil { | |
| 199 | 200 | var body []byte | |
| 200 | 201 | body, err = io.ReadAll(rc) | |
| @@ -205,13 +206,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||
| 205 | 206 | } | |
| 206 | 207 | errs <- err | |
| 207 | 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 | 210 | if err == nil && len(tree.Entries) != 2 { | |
| 210 | 211 | err = fmt.Errorf("tree of %d entries", len(tree.Entries)) | |
| 211 | 212 | } | |
| 212 | 213 | errs <- err | |
| 213 | 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 | 216 | errs <- err | |
| 216 | 217 | } | |
| 217 | 218 | } | |
| @@ -227,7 +228,7 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||
| 227 | 228 | ||
| 228 | 229 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | |
| 229 | 230 | g, data := newReader(t, nil) | |
| 230 | - | h := newHistory(t, data) | |
| 231 | + | h := readertest.NewHistory(t, data) | |
| 231 | 232 | ||
| 232 | 233 | // Many requests, some with a context that dies at a random point. Whatever | |
| 233 | 234 | // state a killed process was in, later requests must get right answers. | |
| @@ -236,10 +237,10 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | |||
| 236 | 237 | wg.Go(func() { | |
| 237 | 238 | for j := range 20 { | |
| 238 | 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 | 241 | cancel() | |
| 241 | 242 | if err == nil { | |
| 242 | - | assert.Equal(t, h.c2, res.CommitID.String()) | |
| 243 | + | assert.Equal(t, h.C2, res.CommitID.String()) | |
| 243 | 244 | } else { | |
| 244 | 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 | 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 | 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 | 256 | func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | |
| 256 | 257 | g, data := newReader(t, nil) | |
| 257 | - | f := newFixtureRepo(t, data, "bigone") | |
| 258 | - | f.writeBytes("big", randomBytes(2<<20, 3)).commit("big") | |
| 258 | + | f := readertest.NewFixture(t, data, "bigone") | |
| 259 | + | f.WriteBytes("big", readertest.RandomBytes(2<<20, 3)).Commit("big") | |
| 259 | 260 | ||
| 260 | 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 | 263 | require.NoError(t, err) | |
| 263 | 264 | pids := g.BlobStreamPIDs() | |
| 264 | 265 | require.Len(t, pids, 1) | |
| @@ -273,7 +274,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | |||
| 273 | 274 | assert.Equal(t, 0, g.BlobStreams()) | |
| 274 | 275 | ||
| 275 | 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 | 278 | require.NoError(t, err) | |
| 278 | 279 | pids = g.BlobStreamPIDs() | |
| 279 | 280 | require.Len(t, pids, 1) | |
| @@ -284,7 +285,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) { | |||
| 284 | 285 | // A context that is already cancelled starts nothing. | |
| 285 | 286 | cctx, cancel2 := context.WithCancel(context.Background()) | |
| 286 | 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 | 289 | assert.ErrorIs(t, err, context.Canceled) | |
| 289 | 290 | assert.Equal(t, 0, g.BlobStreams()) | |
| 290 | 291 | } | |
| @@ -299,18 +300,18 @@ func TestOpenBlobStreams50MiB(t *testing.T) { | |||
| 299 | 300 | } | |
| 300 | 301 | ctx := context.Background() | |
| 301 | 302 | g, data := newReader(t, nil) | |
| 302 | - | f := newFixtureRepo(t, data, "huge") | |
| 303 | + | f := readertest.NewFixture(t, data, "huge") | |
| 303 | 304 | const size = 50 << 20 | |
| 304 | - | content := randomBytes(size, 42) | |
| 305 | + | content := readertest.RandomBytes(size, 42) | |
| 305 | 306 | want := sha256.Sum256(content) | |
| 306 | - | f.writeBytes("huge.bin", content).commit("huge") | |
| 307 | + | f.WriteBytes("huge.bin", content).Commit("huge") | |
| 307 | 308 | content = nil //nolint:ineffassign // let the 50 MiB go before measuring | |
| 308 | 309 | ||
| 309 | 310 | runtime.GC() | |
| 310 | 311 | var before, after runtime.MemStats | |
| 311 | 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 | 315 | require.NoError(t, err) | |
| 315 | 316 | assert.Equal(t, int64(size), info.Size) | |
| 316 | 317 | assert.True(t, info.Binary) | |
| @@ -383,10 +384,10 @@ func TestExecSemaphoreBoundsOneShotCommands(t *testing.T) { | |||
| 383 | 384 | func TestBlobStreamsAreBounded(t *testing.T) { | |
| 384 | 385 | ctx := context.Background() | |
| 385 | 386 | g, data := newReader(t, func(c *gitcli.Config) { c.MaxBlobStreams = 2 }) | |
| 386 | - | f := newFixtureRepo(t, data, "bigone") | |
| 387 | - | f.writeBytes("big", randomBytes(600<<10, 5)).commit("big") | |
| 387 | + | f := readertest.NewFixture(t, data, "bigone") | |
| 388 | + | f.WriteBytes("big", readertest.RandomBytes(600<<10, 5)).Commit("big") | |
| 388 | 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 | 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 | 235 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | |
| 236 | 236 | // first, with up to one `cat-file` request per candidate on the repository's | |
| 237 | 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 | 240 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | |
| 239 | 241 | dir, err := g.repoDir(repo) | |
| 240 | 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 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package gitcli_test | |
| 3 | + | package readertest | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -18,74 +18,61 @@ import ( | |||
| 18 | 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 | 56 | t.Helper() | |
| 23 | 57 | r, err := domain.ParseRevision(s) | |
| 24 | 58 | require.NoError(t, err) | |
| 25 | 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 | 64 | t.Helper() | |
| 30 | 65 | p, err := domain.ParseTreePath(s) | |
| 31 | 66 | require.NoError(t, err) | |
| 32 | 67 | return p | |
| 33 | 68 | } | |
| 34 | 69 | ||
| 35 | - | // history is the repository of the read tests: three commits on main, a | |
| 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) { | |
| 70 | + | func testListRefs(t *testing.T, mk Maker) { | |
| 84 | 71 | ctx := context.Background() | |
| 85 | - | g, data := newReader(t, nil) | |
| 86 | - | h := newHistory(t, data) | |
| 72 | + | g, data := mk(t) | |
| 73 | + | h := NewHistory(t, data) | |
| 87 | 74 | ||
| 88 | - | refs, err := g.ListRefs(ctx, h.name) | |
| 75 | + | refs, err := g.ListRefs(ctx, h.Name) | |
| 89 | 76 | require.NoError(t, err) | |
| 90 | 77 | ||
| 91 | 78 | type row struct { | |
| @@ -99,27 +86,27 @@ func TestListRefs(t *testing.T) { | |||
| 99 | 86 | for _, r := range refs { | |
| 100 | 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 | 90 | assert.Equal(t, []row{ | |
| 104 | - | {"HEAD", "main", domain.RefKindHead, h.merge, h.merge}, | |
| 105 | - | {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.side, h.side}, | |
| 106 | - | {"refs/heads/main", "main", domain.RefKindBranch, h.merge, h.merge}, | |
| 107 | - | {"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}, | |
| 109 | - | {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.c3, h.c3}, | |
| 91 | + | {"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge}, | |
| 92 | + | {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side}, | |
| 93 | + | {"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge}, | |
| 94 | + | {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1}, | |
| 95 | + | {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2}, | |
| 96 | + | {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3}, | |
| 110 | 97 | {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""}, | |
| 111 | - | {"refs/tags/v1", "v1", domain.RefKindTag, h.c1, h.c1}, | |
| 112 | - | {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.c2}, | |
| 113 | - | {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.c2}, | |
| 98 | + | {"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1}, | |
| 99 | + | {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2}, | |
| 100 | + | {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2}, | |
| 114 | 101 | }, got) | |
| 115 | 102 | } | |
| 116 | 103 | ||
| 117 | - | func TestListRefsEmptyAndMissing(t *testing.T) { | |
| 104 | + | func testListRefsEmptyAndMissing(t *testing.T, mk Maker) { | |
| 118 | 105 | ctx := context.Background() | |
| 119 | - | g, data := newReader(t, nil) | |
| 120 | - | f := newFixtureRepo(t, data, "empty") | |
| 106 | + | g, data := mk(t) | |
| 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 | 110 | require.NoError(t, err) | |
| 124 | 111 | require.Len(t, refs, 1) | |
| 125 | 112 | assert.Equal(t, "HEAD", refs[0].Name) | |
| @@ -127,85 +114,85 @@ func TestListRefsEmptyAndMissing(t *testing.T) { | |||
| 127 | 114 | assert.True(t, refs[0].ID.IsZero()) | |
| 128 | 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 | 118 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 132 | 119 | ||
| 133 | 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 | 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 | 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 | 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 | 130 | ctx := context.Background() | |
| 144 | - | g, data := newReader(t, nil) | |
| 145 | - | h := newHistory(t, data) | |
| 146 | - | h.git("update-ref", "--no-deref", "HEAD", h.c1) | |
| 131 | + | g, data := mk(t) | |
| 132 | + | h := NewHistory(t, data) | |
| 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 | 136 | require.NoError(t, err) | |
| 150 | 137 | assert.Equal(t, "HEAD", refs[0].Name) | |
| 151 | 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 | 142 | ctx := context.Background() | |
| 156 | - | g, data := newReader(t, nil) | |
| 157 | - | h := newHistory(t, data) | |
| 143 | + | g, data := mk(t) | |
| 144 | + | h := NewHistory(t, data) | |
| 158 | 145 | ||
| 159 | 146 | tests := []struct { | |
| 160 | 147 | rev string | |
| 161 | 148 | commit string | |
| 162 | 149 | ref string | |
| 163 | 150 | }{ | |
| 164 | - | {"", h.merge, ""}, | |
| 165 | - | {"HEAD", h.merge, ""}, | |
| 166 | - | {"main", h.merge, "refs/heads/main"}, | |
| 167 | - | {"feature/x", h.side, "refs/heads/feature/x"}, | |
| 168 | - | {"refs/heads/feature/x", h.side, "refs/heads/feature/x"}, | |
| 169 | - | {"v1", h.c1, "refs/tags/v1"}, | |
| 170 | - | {"v2", h.c2, "refs/tags/v2"}, | |
| 171 | - | {"v2-again", h.c2, "refs/tags/v2-again"}, | |
| 172 | - | {"refs/tags/v2", h.c2, "refs/tags/v2"}, | |
| 173 | - | {h.c2, h.c2, ""}, | |
| 174 | - | {h.c2[:7], h.c2, ""}, | |
| 175 | - | {h.c2[:12], h.c2, ""}, | |
| 151 | + | {"", h.Merge, ""}, | |
| 152 | + | {"HEAD", h.Merge, ""}, | |
| 153 | + | {"main", h.Merge, "refs/heads/main"}, | |
| 154 | + | {"feature/x", h.Side, "refs/heads/feature/x"}, | |
| 155 | + | {"refs/heads/feature/x", h.Side, "refs/heads/feature/x"}, | |
| 156 | + | {"v1", h.C1, "refs/tags/v1"}, | |
| 157 | + | {"v2", h.C2, "refs/tags/v2"}, | |
| 158 | + | {"v2-again", h.C2, "refs/tags/v2-again"}, | |
| 159 | + | {"refs/tags/v2", h.C2, "refs/tags/v2"}, | |
| 160 | + | {h.C2, h.C2, ""}, | |
| 161 | + | {h.C2[:7], h.C2, ""}, | |
| 162 | + | {h.C2[:12], h.C2, ""}, | |
| 176 | 163 | } | |
| 177 | 164 | for _, tt := range tests { | |
| 178 | 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 | 167 | require.NoError(t, err) | |
| 181 | 168 | assert.Equal(t, tt.commit, got.CommitID.String()) | |
| 182 | 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 | 173 | t.Run("missing "+bad, func(t *testing.T) { | |
| 187 | 174 | r, err := domain.ParseRevision(bad) | |
| 188 | 175 | if err != nil { | |
| 189 | 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 | 179 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 193 | 180 | }) | |
| 194 | 181 | } | |
| 195 | 182 | ||
| 196 | 183 | t.Run("a branch wins over a tag of the same name", func(t *testing.T) { | |
| 197 | - | h.git("branch", "both", h.c1) | |
| 198 | - | h.git("tag", "both", h.c2) | |
| 199 | - | got, err := g.ResolveRevision(ctx, h.name, rev(t, "both")) | |
| 184 | + | h.Git("branch", "both", h.C1) | |
| 185 | + | h.Git("tag", "both", h.C2) | |
| 186 | + | got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both")) | |
| 200 | 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 | 193 | ctx := context.Background() | |
| 207 | - | g, data := newReader(t, nil) | |
| 208 | - | h := newHistory(t, data) | |
| 194 | + | g, data := mk(t) | |
| 195 | + | h := NewHistory(t, data) | |
| 209 | 196 | ||
| 210 | 197 | tests := []struct { | |
| 211 | 198 | path string | |
| @@ -214,22 +201,22 @@ func TestResolveRefPath(t *testing.T) { | |||
| 214 | 201 | id string | |
| 215 | 202 | rest string | |
| 216 | 203 | }{ | |
| 217 | - | {"main", "main", "refs/heads/main", h.merge, ""}, | |
| 218 | - | {"main/d/f", "main", "refs/heads/main", h.merge, "d/f"}, | |
| 219 | - | {"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"}, | |
| 221 | - | {"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"}, | |
| 204 | + | {"main", "main", "refs/heads/main", h.Merge, ""}, | |
| 205 | + | {"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"}, | |
| 206 | + | {"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""}, | |
| 207 | + | {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"}, | |
| 208 | + | {"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""}, | |
| 209 | + | {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"}, | |
| 223 | 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"}, | |
| 225 | - | {"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"}, | |
| 227 | - | {h.c1 + "/d", h.c1, "", h.c1, "d"}, | |
| 228 | - | {h.c1[:8] + "/d/f", h.c1[:8], "", h.c1, "d/f"}, | |
| 211 | + | {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"}, | |
| 212 | + | {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"}, | |
| 213 | + | {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"}, | |
| 214 | + | {h.C1 + "/d", h.C1, "", h.C1, "d"}, | |
| 215 | + | {h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"}, | |
| 229 | 216 | } | |
| 230 | 217 | for _, tt := range tests { | |
| 231 | 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 | 220 | require.NoError(t, err) | |
| 234 | 221 | assert.Equal(t, tt.name, got.Name) | |
| 235 | 222 | assert.Equal(t, tt.ref, got.Ref) | |
| @@ -239,24 +226,24 @@ func TestResolveRefPath(t *testing.T) { | |||
| 239 | 226 | } | |
| 240 | 227 | for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} { | |
| 241 | 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 | 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 | 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 | 238 | ctx := context.Background() | |
| 252 | - | g, data := newReader(t, nil) | |
| 253 | - | h := newHistory(t, data) | |
| 239 | + | g, data := mk(t) | |
| 240 | + | h := NewHistory(t, data) | |
| 254 | 241 | ||
| 255 | 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 | 244 | require.NoError(t, err) | |
| 258 | - | assert.Equal(t, h.merge, tree.CommitID.String()) | |
| 259 | - | assert.Equal(t, h.git("rev-parse", h.merge+"^{tree}"), tree.ID.String()) | |
| 245 | + | assert.Equal(t, h.Merge, tree.CommitID.String()) | |
| 246 | + | assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String()) | |
| 260 | 247 | type row struct { | |
| 261 | 248 | name, path string | |
| 262 | 249 | kind domain.EntryKind | |
| @@ -282,7 +269,7 @@ func TestGetTree(t *testing.T) { | |||
| 282 | 269 | }) | |
| 283 | 270 | ||
| 284 | 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 | 273 | require.NoError(t, err) | |
| 287 | 274 | require.Len(t, tree.Entries, 2) | |
| 288 | 275 | assert.Equal(t, "sub", tree.Entries[0].Name) | |
| @@ -290,42 +277,42 @@ func TestGetTree(t *testing.T) { | |||
| 290 | 277 | assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind) | |
| 291 | 278 | assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode) | |
| 292 | 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 | 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 | 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 | 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 | 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 | 291 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 305 | 292 | }) | |
| 306 | 293 | ||
| 307 | 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 | 296 | require.NoError(t, err) | |
| 310 | 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 | 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 | 304 | ctx := context.Background() | |
| 318 | - | g, data := newReader(t, nil) | |
| 319 | - | h := newHistory(t, data) | |
| 305 | + | g, data := mk(t) | |
| 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 | 309 | require.NoError(t, err) | |
| 323 | 310 | got := map[string]string{} | |
| 324 | 311 | for _, e := range tree.Entries { | |
| 325 | 312 | require.NotNil(t, e.LastCommit, e.Path) | |
| 326 | 313 | got[e.Path] = e.LastCommit.Summary | |
| 327 | 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 | 317 | assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d") | |
| 331 | 318 | assert.Equal(t, "second", got["b.txt"]) | |
| @@ -335,17 +322,17 @@ func TestGetTreeLastCommit(t *testing.T) { | |||
| 335 | 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 | 326 | ctx := context.Background() | |
| 340 | - | g, data := newReader(t, nil) | |
| 341 | - | f := newFixtureRepo(t, data, "wide") | |
| 327 | + | g, data := mk(t) | |
| 328 | + | f := NewFixture(t, data, "wide") | |
| 342 | 329 | n := domain.MaxLastCommitEntries + 5 | |
| 343 | 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 | 336 | require.NoError(t, err) | |
| 350 | 337 | require.Len(t, tree.Entries, n) | |
| 351 | 338 | assert.True(t, tree.LastCommitsOmitted) | |
| @@ -359,7 +346,7 @@ func TestGetTreeLastCommitBound(t *testing.T) { | |||
| 359 | 346 | assert.NotNil(t, tree.Entries[0].LastCommit) | |
| 360 | 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 | 350 | require.NoError(t, err) | |
| 364 | 351 | assert.False(t, tree.LastCommitsOmitted) | |
| 365 | 352 | } | |
| @@ -375,18 +362,18 @@ func itoa(i int) string { | |||
| 375 | 362 | return string(b) | |
| 376 | 363 | } | |
| 377 | 364 | ||
| 378 | - | func TestListCommits(t *testing.T) { | |
| 365 | + | func testListCommits(t *testing.T, mk Maker) { | |
| 379 | 366 | ctx := context.Background() | |
| 380 | - | g, data := newReader(t, nil) | |
| 381 | - | h := newHistory(t, data) | |
| 367 | + | g, data := mk(t) | |
| 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 | 371 | require.Len(t, oracle, 6) | |
| 385 | 372 | ||
| 386 | 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 | 375 | require.NoError(t, err) | |
| 389 | - | assert.Equal(t, h.merge, page.Head.String()) | |
| 376 | + | assert.Equal(t, h.Merge, page.Head.String()) | |
| 390 | 377 | assert.False(t, page.HasMore) | |
| 391 | 378 | var ids []string | |
| 392 | 379 | for _, c := range page.Commits { | |
| @@ -399,7 +386,7 @@ func TestListCommits(t *testing.T) { | |||
| 399 | 386 | var ids []string | |
| 400 | 387 | skip := 0 | |
| 401 | 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 | 390 | require.NoError(t, err) | |
| 404 | 391 | for _, c := range page.Commits { | |
| 405 | 392 | ids = append(ids, c.ID.String()) | |
| @@ -414,33 +401,33 @@ func TestListCommits(t *testing.T) { | |||
| 414 | 401 | }) | |
| 415 | 402 | ||
| 416 | 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 | 405 | require.NoError(t, err) | |
| 419 | 406 | var ids []string | |
| 420 | 407 | for _, c := range page.Commits { | |
| 421 | 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 | 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 | 414 | require.NoError(t, err) | |
| 428 | 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 | 417 | require.NoError(t, err) | |
| 431 | 418 | assert.Empty(t, page.Commits) | |
| 432 | 419 | }) | |
| 433 | 420 | ||
| 434 | 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 | 423 | require.NoError(t, err) | |
| 437 | 424 | require.Len(t, page.Commits, 2) | |
| 438 | - | assert.Equal(t, h.c2, page.Head.String()) | |
| 439 | - | assert.Equal(t, h.c2, page.Commits[0].ID.String()) | |
| 425 | + | assert.Equal(t, h.C2, page.Head.String()) | |
| 426 | + | assert.Equal(t, h.C2, page.Commits[0].ID.String()) | |
| 440 | 427 | }) | |
| 441 | 428 | ||
| 442 | 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 | 431 | require.NoError(t, err) | |
| 445 | 432 | require.Len(t, page.Commits, 1) | |
| 446 | 433 | c := page.Commits[0] | |
| @@ -451,25 +438,26 @@ func TestListCommits(t *testing.T) { | |||
| 451 | 438 | assert.Equal(t, "Grace Hopper", c.Committer.Name) | |
| 452 | 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 | 442 | require.NoError(t, err) | |
| 456 | 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 | 447 | t.Run("bad queries", func(t *testing.T) { | |
| 461 | 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 | 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 | 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 | 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 | 461 | t.Helper() | |
| 474 | 462 | id, err := domain.ParseObjectID(s) | |
| 475 | 463 | require.NoError(t, err) | |
| @@ -484,14 +472,14 @@ func idStrings(ids []domain.ObjectID) []string { | |||
| 484 | 472 | return out | |
| 485 | 473 | } | |
| 486 | 474 | ||
| 487 | - | func TestOpenBlob(t *testing.T) { | |
| 475 | + | func testOpenBlob(t *testing.T, mk Maker) { | |
| 488 | 476 | ctx := context.Background() | |
| 489 | - | g, data := newReader(t, nil) | |
| 490 | - | h := newHistory(t, data) | |
| 477 | + | g, data := mk(t) | |
| 478 | + | h := NewHistory(t, data) | |
| 491 | 479 | ||
| 492 | 480 | read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) { | |
| 493 | 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 | 483 | require.NoError(t, err) | |
| 496 | 484 | body, err := io.ReadAll(rc) | |
| 497 | 485 | require.NoError(t, err) | |
| @@ -506,7 +494,7 @@ func TestOpenBlob(t *testing.T) { | |||
| 506 | 494 | assert.Equal(t, uint32(0o100644), info.Mode) | |
| 507 | 495 | assert.False(t, info.Binary) | |
| 508 | 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 | 499 | info, body = read(t, "main", "bin") | |
| 512 | 500 | assert.Equal(t, "\x00\x02", body) | |
| @@ -529,30 +517,30 @@ func TestOpenBlob(t *testing.T) { | |||
| 529 | 517 | assert.Equal(t, "text/plain", info.MediaType) | |
| 530 | 518 | ||
| 531 | 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 | 521 | assert.ErrorIs(t, err, want, p) | |
| 534 | 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 | 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 | 529 | ctx := context.Background() | |
| 542 | - | g, data := newReader(t, nil) | |
| 543 | - | f := newFixtureRepo(t, data, "media") | |
| 530 | + | g, data := mk(t) | |
| 531 | + | f := NewFixture(t, data, "media") | |
| 544 | 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). | |
| 546 | - | writeBytes("noext", png). | |
| 547 | - | write("page.html", "<!doctype html><html><body>hi</body></html>\n"). | |
| 548 | - | write("style.css", "a { color: red }\n"). | |
| 549 | - | write("data.json", `{"a":1}`+"\n"). | |
| 550 | - | write("notes", "plain words\n"). | |
| 551 | - | write("main.go", "package main\n"). | |
| 552 | - | writeBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}). | |
| 553 | - | writeBytes("fake.txt", []byte{'a', 0, 'b'}). | |
| 554 | - | write("latin", "caf\xe9\n") | |
| 555 | - | f.commit("media") | |
| 533 | + | f.WriteBytes("logo.png", png). | |
| 534 | + | WriteBytes("noext", png). | |
| 535 | + | Write("page.html", "<!doctype html><html><body>hi</body></html>\n"). | |
| 536 | + | Write("style.css", "a { color: red }\n"). | |
| 537 | + | Write("data.json", `{"a":1}`+"\n"). | |
| 538 | + | Write("notes", "plain words\n"). | |
| 539 | + | Write("main.go", "package main\n"). | |
| 540 | + | WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}). | |
| 541 | + | WriteBytes("fake.txt", []byte{'a', 0, 'b'}). | |
| 542 | + | Write("latin", "caf\xe9\n") | |
| 543 | + | f.Commit("media") | |
| 556 | 544 | ||
| 557 | 545 | want := map[string]struct { | |
| 558 | 546 | media string | |
| @@ -570,7 +558,7 @@ func TestOpenBlobMediaTypes(t *testing.T) { | |||
| 570 | 558 | "latin": {"text/plain", false}, | |
| 571 | 559 | } | |
| 572 | 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 | 562 | require.NoError(t, err, p) | |
| 575 | 563 | require.NoError(t, rc.Close()) | |
| 576 | 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 | 570 | ctx := context.Background() | |
| 583 | - | g, data := newReader(t, nil) | |
| 584 | - | f := newFixtureRepo(t, data, "sniff") | |
| 571 | + | g, data := mk(t) | |
| 572 | + | f := NewFixture(t, data, "sniff") | |
| 585 | 573 | late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen | |
| 586 | 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 | 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)). | |
| 590 | - | writeBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...)) | |
| 591 | - | f.commit("sniff") | |
| 577 | + | f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)). | |
| 578 | + | WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...)) | |
| 579 | + | f.Commit("sniff") | |
| 592 | 580 | ||
| 593 | 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 | 583 | require.NoError(t, err, p) | |
| 596 | 584 | require.NoError(t, rc.Close()) | |
| 597 | 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 | 590 | ctx := context.Background() | |
| 603 | - | g, data := newReader(t, nil) | |
| 604 | - | f := newFixtureRepo(t, data, "large") | |
| 605 | - | content := randomBytes(1<<20+17, 7) | |
| 606 | - | f.writeBytes("big.bin", content).commit("big") | |
| 591 | + | g, data := mk(t) | |
| 592 | + | f := NewFixture(t, data, "large") | |
| 593 | + | content := RandomBytes(1<<20+17, 7) | |
| 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 | 597 | require.NoError(t, err) | |
| 610 | 598 | assert.Equal(t, int64(len(content)), info.Size) | |
| 611 | - | assert.Equal(t, 1, g.BlobStreams()) | |
| 612 | 599 | got, err := io.ReadAll(rc) | |
| 613 | 600 | require.NoError(t, err) | |
| 614 | 601 | assert.Equal(t, content, got) | |
| 615 | 602 | require.NoError(t, rc.Close()) | |
| 616 | 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) { | |
| 621 | - | g, data := newReader(t, nil) | |
| 622 | - | h := newHistory(t, data) | |
| 606 | + | func testCancelledContext(t *testing.T, mk Maker) { | |
| 607 | + | g, data := mk(t) | |
| 608 | + | h := NewHistory(t, data) | |
| 623 | 609 | ||
| 624 | 610 | ctx, cancel := context.WithCancel(context.Background()) | |
| 625 | 611 | cancel() | |
| 626 | - | _, err := g.ListRefs(ctx, h.name) | |
| 612 | + | _, err := g.ListRefs(ctx, h.Name) | |
| 627 | 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 | 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 | 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 | 619 | assert.ErrorIs(t, err, context.Canceled) | |
| 634 | 620 | ||
| 635 | 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 | 623 | assert.NoError(t, err) | |
| 638 | 624 | } | |
| 639 | 625 | ||
| 640 | - | func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | |
| 626 | + | func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) { | |
| 641 | 627 | ctx := context.Background() | |
| 642 | - | g, data := newReader(t, nil) | |
| 643 | - | h := newHistory(t, data) | |
| 628 | + | g, data := mk(t) | |
| 629 | + | h := NewHistory(t, data) | |
| 644 | 630 | ||
| 645 | 631 | // Revisions that look like options or git revision syntax are rejected by | |
| 646 | 632 | // the domain; ones that pass it are plain names for the reader. | |
| @@ -648,9 +634,9 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | |||
| 648 | 634 | _, err := domain.ParseRevision(s) | |
| 649 | 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 | 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 | 640 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 655 | 641 | var pe *domain.InvalidNameError | |
| 656 | 642 | assert.False(t, errors.As(err, &pe)) | |
| @@ -658,60 +644,60 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) { | |||
| 658 | 644 | ||
| 659 | 645 | // A full object id is an object id before it is a ref: a branch or a tag | |
| 660 | 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 | 648 | ctx := context.Background() | |
| 663 | - | g, data := newReader(t, nil) | |
| 664 | - | h := newHistory(t, data) | |
| 649 | + | g, data := mk(t) | |
| 650 | + | h := NewHistory(t, data) | |
| 665 | 651 | ||
| 666 | 652 | // Git itself warns about these refs and prefers the object id. | |
| 667 | - | h.git("-c", "advice.objectNameWarning=false", "branch", h.c1, h.c3) | |
| 668 | - | h.git("-c", "advice.objectNameWarning=false", "tag", h.c2, h.c3) | |
| 653 | + | h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, 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 | 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 | 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 | 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 | 665 | // An abbreviation is still a name first, as before. | |
| 680 | - | h.git("branch", "abc1234", h.c3) | |
| 681 | - | if !strings.HasPrefix(h.c1, "abc1234") { | |
| 682 | - | got, err = g.ResolveRevision(ctx, h.name, rev(t, "abc1234")) | |
| 666 | + | h.Git("branch", "abc1234", h.C3) | |
| 667 | + | if !strings.HasPrefix(h.C1, "abc1234") { | |
| 668 | + | got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234")) | |
| 683 | 669 | require.NoError(t, err) | |
| 684 | - | assert.Equal(t, h.c3, got.CommitID.String()) | |
| 670 | + | assert.Equal(t, h.C3, got.CommitID.String()) | |
| 685 | 671 | assert.Equal(t, "refs/heads/abc1234", got.Ref) | |
| 686 | 672 | } | |
| 687 | 673 | ||
| 688 | 674 | // The ref is used when no object has that id. | |
| 689 | 675 | ghost := strings.Repeat("ab", 20) | |
| 690 | - | h.git("-c", "advice.objectNameWarning=false", "branch", ghost, h.c3) | |
| 691 | - | got, err = g.ResolveRevision(ctx, h.name, rev(t, ghost)) | |
| 676 | + | h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3) | |
| 677 | + | got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost)) | |
| 692 | 678 | require.NoError(t, err) | |
| 693 | - | assert.Equal(t, h.c3, got.CommitID.String()) | |
| 679 | + | assert.Equal(t, h.C3, got.CommitID.String()) | |
| 694 | 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 | 684 | ctx := context.Background() | |
| 699 | - | g, data := newReader(t, nil) | |
| 700 | - | f := newFixtureRepo(t, data, "wide") | |
| 685 | + | g, data := mk(t) | |
| 686 | + | f := NewFixture(t, data, "wide") | |
| 701 | 687 | n := domain.MaxTreeEntries + 7 | |
| 702 | 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 | 694 | require.NoError(t, err) | |
| 709 | 695 | assert.True(t, tree.Truncated) | |
| 710 | 696 | require.Len(t, tree.Entries, domain.MaxTreeEntries) | |
| 711 | 697 | assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order") | |
| 712 | 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 | 701 | require.NoError(t, err) | |
| 716 | 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 | + | } | |