detached b54f0d3

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +3304 -566
M go.mod +1 -0
@@ -13,6 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed
16+ bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0
1617 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1718 connectrpc.com/connect v1.21.0
1819 github.com/a-h/templ v0.3.1020
M go.sum +2 -0
@@ -1,5 +1,7 @@
11 bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed h1:JtPA+6SU8PThRFwOaFVLnscyBhNE9ObT5nJBzi6t+HQ=
22 bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0 h1:sR/gJD0SYTESQehBidGLjSBCsOWUsr3bO8ySXx1/5gI=
4+bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
35 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 h1:PVTDOWTg9JJZrnJznTHSCB1Vcc+x2n6zMgZkKiFwvuw=
46 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3/go.mod h1:5Tb7lsUx00GVtTC6rGRWG9JbBMKlZ8hi/klP8L/DcEA=
57 buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU=
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 (
1313 "github.com/stretchr/testify/require"
1414
1515 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/readertest"
1617 )
1718
1819 // unified renders hunks the way git prints them after the "+++" line.
@@ -58,16 +59,16 @@ func gitSections(out string) []string {
5859 }
5960
6061 // 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)
6465 return gitSections(strings.ToValidUTF8(out, "\uFFFD"))
6566 }
6667
6768 // 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)
7172 if out == "" {
7273 return nil
7374 }
@@ -76,11 +77,11 @@ func (f *fixtureRepo) showNumstat(id string) []string {
7677
7778 // assertMatchesGitShow checks the structured diff of a commit against the
7879 // 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) {
8081 t.Helper()
81- sections := f.showSections(id)
82+ sections := showSections(f, id)
8283 var adds, dels int
83- numstat := f.showNumstat(id)
84+ numstat := showNumstat(f, id)
8485 require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")
8586 si := 0
8687 for i, file := range d.Diff.Files {
@@ -113,21 +114,21 @@ func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.Comm
113114 func TestGetCommitMatchesGitShow(t *testing.T) {
114115 ctx := context.Background()
115116 g, data := newReader(t, nil)
116- h := newHistory(t, data)
117+ h := readertest.NewHistory(t, data)
117118
118119 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},
124125 } {
125126 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())
127128 require.NoError(t, err)
128129 assert.Equal(t, tt.id, d.Commit.ID.String())
129130 assert.False(t, d.Diff.Truncated)
130- assertMatchesGitShow(t, h.fixtureRepo, tt.id, d)
131+ assertMatchesGitShow(t, h.Fixture, tt.id, d)
131132 })
132133 }
133134 }
@@ -135,7 +136,7 @@ func TestGetCommitMatchesGitShow(t *testing.T) {
135136 func TestGetCommitFileMetadata(t *testing.T) {
136137 ctx := context.Background()
137138 g, data := newReader(t, nil)
138- h := newHistory(t, data)
139+ h := readertest.NewHistory(t, data)
139140
140141 byPath := func(d domain.Diff) map[string]domain.FileDiff {
141142 m := map[string]domain.FileDiff{}
@@ -144,11 +145,11 @@ func TestGetCommitFileMetadata(t *testing.T) {
144145 }
145146 return m
146147 }
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)) }
148149
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())
150151 require.NoError(t, err)
151- assert.Equal(t, h.c2, d.Commit.ID.String())
152+ assert.Equal(t, h.C2, d.Commit.ID.String())
152153 files := byPath(d.Diff)
153154 require.Len(t, files, 5)
154155
@@ -159,8 +160,8 @@ func TestGetCommitFileMetadata(t *testing.T) {
159160 assert.Equal(t, 0, ren.Deletions)
160161 assert.Equal(t, uint32(0o100644), ren.OldMode)
161162 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)
164165 require.Len(t, ren.Hunks, 1)
165166 assert.Equal(t, domain.Hunk{
166167 OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,
@@ -203,7 +204,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
203204 last := add.Hunks[0].Lines[1]
204205 assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)
205206
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())
207208 require.NoError(t, err)
208209 require.Len(t, d.Diff.Files, 1)
209210 tc := d.Diff.Files[0]
@@ -216,9 +217,9 @@ func TestGetCommitFileMetadata(t *testing.T) {
216217 func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
217218 ctx := context.Background()
218219 g, data := newReader(t, nil)
219- h := newHistory(t, data)
220+ h := readertest.NewHistory(t, data)
220221
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())
222223 require.NoError(t, err)
223224 require.Len(t, d.Commit.Parents, 2)
224225 require.Len(t, d.Diff.Files, 1)
@@ -229,10 +230,10 @@ func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
229230 func TestGetCommitEmptyCommit(t *testing.T) {
230231 ctx := context.Background()
231232 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")
234235
235- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
236+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
236237 require.NoError(t, err)
237238 assert.Equal(t, id, d.Commit.ID.String())
238239 assert.Empty(t, d.Diff.Files)
@@ -243,9 +244,9 @@ func TestGetCommitEmptyCommit(t *testing.T) {
243244 func TestGetCommitErrors(t *testing.T) {
244245 ctx := context.Background()
245246 g, data := newReader(t, nil)
246- h := newHistory(t, data)
247+ h := readertest.NewHistory(t, data)
247248
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())
249250 assert.ErrorIs(t, err, domain.ErrNotFound)
250251 _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())
251252 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -254,19 +255,19 @@ func TestGetCommitErrors(t *testing.T) {
254255 func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
255256 ctx := context.Background()
256257 g, data := newReader(t, nil)
257- f := newFixtureRepo(t, data, "hunks")
258+ f := readertest.NewFixture(t, data, "hunks")
258259 var src strings.Builder
259260 src.WriteString("package x\n\nfunc Alpha() {\n")
260261 for i := range 40 {
261262 fmt.Fprintf(&src, "\tline(%d)\n", i)
262263 }
263264 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")
268269
269- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())
270+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
270271 require.NoError(t, err)
271272 assertMatchesGitShow(t, f, id, d)
272273 var gofile, latin domain.FileDiff
@@ -286,11 +287,11 @@ func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
286287 }
287288
288289 // 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 {
290291 for p, n := range files {
291- f.lines(p, strings.ReplaceAll(p, "/", "_"), n)
292+ f.Lines(p, strings.ReplaceAll(p, "/", "_"), n)
292293 }
293- return f.commit(msg)
294+ return f.Commit(msg)
294295 }
295296
296297 func TestDiffLimits(t *testing.T) {
@@ -308,9 +309,9 @@ func TestDiffLimits(t *testing.T) {
308309 }
309310
310311 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")
312313 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)
314315 require.NoError(t, err)
315316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
316317
@@ -327,8 +328,8 @@ func TestDiffLimits(t *testing.T) {
327328 })
328329
329330 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")
332333 // Every 7th line changes: ~428 changed lines, but the hunks merge
333334 // into one that spans the file with its context.
334335 var b strings.Builder
@@ -339,10 +340,10 @@ func TestDiffLimits(t *testing.T) {
339340 fmt.Fprintf(&b, "l %d\n", i)
340341 }
341342 }
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)
346347 require.NoError(t, err)
347348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
348349 assert.True(t, big.Truncated)
@@ -355,7 +356,7 @@ func TestDiffLimits(t *testing.T) {
355356 })
356357
357358 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")
359360 files := map[string]int{}
360361 for i := range 11 {
361362 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
@@ -364,7 +365,7 @@ func TestDiffLimits(t *testing.T) {
364365 files["h-big"] = 1500 // does not fit
365366 files["i-small"] = 10 // fits
366367 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)
368369 require.NoError(t, err)
369370 require.Len(t, d.Diff.Files, 14)
370371 total := 0
@@ -387,12 +388,12 @@ func TestDiffLimits(t *testing.T) {
387388 })
388389
389390 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")
391392 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")
393394 }
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)
396397 require.NoError(t, err)
397398 assert.Len(t, d.Diff.Files, 300)
398399 assert.True(t, d.Diff.Truncated)
@@ -406,28 +407,28 @@ func TestDiffLimits(t *testing.T) {
406407 })
407408
408409 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")
410411 for i := range 300 {
411- f.write(fmt.Sprintf("f%03d", i), "x\n")
412+ f.Write(fmt.Sprintf("f%03d", i), "x\n")
412413 }
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)
415416 require.NoError(t, err)
416417 assert.Len(t, d.Diff.Files, 300)
417418 assert.False(t, d.Diff.Truncated)
418419 })
419420
420421 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")
422423 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})
424425 require.NoError(t, err)
425426 require.Len(t, d.Diff.Files, 2)
426427 assert.False(t, d.Diff.Files[0].Truncated)
427428 assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
428429 assert.Equal(t, 3, d.Stats.FilesChanged)
429430
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})
431432 require.NoError(t, err)
432433 require.Len(t, d.Diff.Files, 3)
433434 assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
@@ -438,9 +439,9 @@ func TestDiffLimits(t *testing.T) {
438439 })
439440
440441 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)
444445 require.NoError(t, err)
445446 long, short := find(d.Diff, "long"), find(d.Diff, "short")
446447 assert.True(t, long.Truncated)
@@ -450,12 +451,12 @@ func TestDiffLimits(t *testing.T) {
450451 })
451452
452453 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)
459460 require.NoError(t, err)
460461 assert.True(t, find(d.Diff, "a-big").Truncated)
461462 assert.True(t, find(d.Diff, "b-bin").Binary)
@@ -463,7 +464,7 @@ func TestDiffLimits(t *testing.T) {
463464 assert.False(t, find(d.Diff, "c-mode").Truncated)
464465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
465466 // What is kept matches git.
466- sections := f.showSections(id)
467+ sections := showSections(f, id)
467468 require.Len(t, sections, 4)
468469 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
469470 })
@@ -472,11 +473,11 @@ func TestDiffLimits(t *testing.T) {
472473 func TestGetCommitStopsGitEarly(t *testing.T) {
473474 ctx := context.Background()
474475 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")
477478
478479 // 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())
480481 require.NoError(t, err)
481482 require.Len(t, d.Diff.Files, 2)
482483 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 (
1111 "github.com/stretchr/testify/require"
1212
1313 "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/readertest"
1415 )
1516
1617 func oidOf(t *testing.T, s string) domain.ObjectID {
@@ -22,25 +23,25 @@ func oidOf(t *testing.T, s string) domain.ObjectID {
2223
2324 // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat.
2425 type mergeFixture struct {
25- f *fixtureRepo
26+ f *readertest.Fixture
2627 a, b, c, d domain.ObjectID
2728 mainTip, featTip domain.ObjectID
2829 }
2930
3031 func newMergeFixture(t *testing.T, data string) mergeFixture {
3132 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)
4445 return mergeFixture{
4546 f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d),
4647 mainTip: oidOf(t, b), featTip: oidOf(t, d),
@@ -53,27 +54,27 @@ func TestBranchHeadAndHasCommit(t *testing.T) {
5354 m := newMergeFixture(t, data)
5455 t.Cleanup(func() { _ = g.Close(ctx) })
5556
56- got, err := g.BranchHead(ctx, m.f.name, "feat")
57+ got, err := g.BranchHead(ctx, m.f.Name, "feat")
5758 require.NoError(t, err)
5859 assert.Equal(t, m.d, got)
5960
6061 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)
6263 assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch)
6364 }
6465 _, err = g.BranchHead(ctx, name(t, "missing"), "main")
6566 assert.ErrorIs(t, err, domain.ErrNotFound)
6667
67- ok, err := g.HasCommit(ctx, m.f.name, m.c)
68+ ok, err := g.HasCommit(ctx, m.f.Name, m.c)
6869 require.NoError(t, err)
6970 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)))
7172 require.NoError(t, err)
7273 assert.False(t, ok)
7374 // 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)
7576 require.NoError(t, err)
76- ok, err = g.HasCommit(ctx, m.f.name, tree)
77+ ok, err = g.HasCommit(ctx, m.f.Name, tree)
7778 require.NoError(t, err)
7879 assert.False(t, ok)
7980 }
@@ -83,23 +84,23 @@ func TestMergeBaseAndAncestry(t *testing.T) {
8384 ctx := context.Background()
8485 m := newMergeFixture(t, data)
8586 // 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())
9091 // 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"))
9596
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)
9798 require.NoError(t, err)
9899 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)
100101 require.NoError(t, err)
101102 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)
103104 assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history")
104105
105106 for _, tc := range []struct {
@@ -112,11 +113,11 @@ func TestMergeBaseAndAncestry(t *testing.T) {
112113 {side, m.featTip, false},
113114 {other, m.featTip, false},
114115 } {
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)
116117 require.NoError(t, err)
117118 assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc)
118119 }
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)
120121 assert.Error(t, err, "an unknown commit is an error, not a plain no")
121122 }
122123
@@ -125,7 +126,7 @@ func TestListRange(t *testing.T) {
125126 ctx := context.Background()
126127 m := newMergeFixture(t, data)
127128
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)
129130 require.NoError(t, err)
130131 assert.False(t, more)
131132 require.Len(t, commits, 2)
@@ -133,21 +134,21 @@ func TestListRange(t *testing.T) {
133134 assert.Equal(t, m.c, commits[1].ID)
134135 assert.Equal(t, "add c", commits[0].Summary)
135136
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)
137138 require.NoError(t, err)
138139 assert.True(t, more)
139140 require.Len(t, commits, 1)
140141
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)
142143 require.NoError(t, err)
143144 assert.False(t, more)
144145 assert.Len(t, commits, 4, "a zero exclude lists the whole history")
145146
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)
147148 require.NoError(t, err)
148149 assert.Empty(t, commits)
149150
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)
151152 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
152153 }
153154
@@ -156,7 +157,7 @@ func TestDiffCommits(t *testing.T) {
156157 ctx := context.Background()
157158 m := newMergeFixture(t, data)
158159
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())
160161 require.NoError(t, err)
161162 assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats)
162163 require.Len(t, diff.Files, 2)
@@ -168,7 +169,7 @@ func TestDiffCommits(t *testing.T) {
168169 assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1))
169170
170171 // 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())
172173 require.NoError(t, err)
173174 assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats)
174175 }
@@ -178,29 +179,29 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) {
178179 ctx := context.Background()
179180 m := newMergeFixture(t, data)
180181
181- tree, err := g.CommitTree(ctx, m.f.name, m.featTip)
182+ tree, err := g.CommitTree(ctx, m.f.Name, m.featTip)
182183 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())
184185
185186 obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" +
186187 "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))
188189 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()))
191192
192- _, err = g.WriteCommit(ctx, m.f.name, []byte("not a commit"))
193+ _, err = g.WriteCommit(ctx, m.f.Name, []byte("not a commit"))
193194 assert.Error(t, err, "git checks the object")
194195
195196 // 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)
197198 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"))
199200
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"))
202203
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)
204205 assert.Error(t, err, "only short branch names")
205206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
206207 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/adapters/gitcli/pool_test.go +47 -46
@@ -20,6 +20,7 @@ import (
2020
2121 "bnema.dev/ermite/internal/adapters/gitcli"
2222 "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/readertest"
2324 )
2425
2526 // alive reports whether a process still exists. The adapter collects its
@@ -43,17 +44,17 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
4344 // The default idle timeout (60 s) never fires during the test: the hooks
4445 // do what the timers would, deterministically.
4546 g, data := newReader(t, nil)
46- h := newHistory(t, data)
47+ h := readertest.NewHistory(t, data)
4748
4849 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{})
5051 require.NoError(t, err)
5152 pids := g.CatFilePIDs()
5253 require.Len(t, pids, 1)
5354 require.True(t, alive(pids[0]))
5455
5556 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)
5758 require.NoError(t, err)
5859 }
5960 assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request")
@@ -68,7 +69,7 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
6869 requireGone(t, pids...)
6970
7071 // 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{})
7273 require.NoError(t, err)
7374 next := g.CatFilePIDs()
7475 require.Len(t, next, 1)
@@ -78,8 +79,8 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
7879 func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) {
7980 ctx := context.Background()
8081 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{})
8384 require.NoError(t, err)
8485 pids := g.CatFilePIDs()
8586 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) {
8990 func TestCloseStopsEveryProcess(t *testing.T) {
9091 ctx := context.Background()
9192 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")
9798
98- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
99+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
99100 require.NoError(t, err)
100- _, err = g.ResolveRevision(ctx, f2.name, domain.Revision{})
101+ _, err = g.ResolveRevision(ctx, f2.Name, domain.Revision{})
101102 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"))
103104 require.NoError(t, err)
104105 _ = rc // left open on purpose: Close must stop its process too
105106
@@ -115,9 +116,9 @@ func TestCloseStopsEveryProcess(t *testing.T) {
115116 requireGone(t, pids...)
116117 assert.Empty(t, g.CatFilePIDs())
117118
118- _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
119+ _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
119120 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"))
121122 assert.ErrorIs(t, err, gitcli.ErrClosed)
122123 assert.NoError(t, g.Close(ctx), "Close is idempotent")
123124 _, err = io.Copy(io.Discard, rc)
@@ -131,9 +132,9 @@ func TestPoolIsBounded(t *testing.T) {
131132 g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 })
132133 var names []domain.RepoName
133134 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)
137138 }
138139 var seen []int
139140 for _, n := range names {
@@ -158,9 +159,9 @@ func TestPoolIsBounded(t *testing.T) {
158159 func TestPoolRestartsAfterProcessDeath(t *testing.T) {
159160 ctx := context.Background()
160161 g, data := newReader(t, nil)
161- h := newHistory(t, data)
162+ h := readertest.NewHistory(t, data)
162163
163- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})
164+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
164165 require.NoError(t, err)
165166 old := g.CatFilePIDs()
166167 require.Len(t, old, 1)
@@ -168,9 +169,9 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
168169 requireGone(t, old[0])
169170
170171 // 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{})
172173 require.NoError(t, err)
173- assert.Equal(t, h.merge, got.CommitID.String())
174+ assert.Equal(t, h.Merge, got.CommitID.String())
174175 now := g.CatFilePIDs()
175176 require.Len(t, now, 1)
176177 assert.NotEqual(t, old, now)
@@ -179,7 +180,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
179180 func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
180181 ctx := context.Background()
181182 g, data := newReader(t, nil)
182- h := newHistory(t, data)
183+ h := readertest.NewHistory(t, data)
183184
184185 var wg sync.WaitGroup
185186 errs := make(chan error, 24*10)
@@ -188,13 +189,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
188189 for j := range 10 {
189190 switch (i + j) % 4 {
190191 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 {
193194 err = fmt.Errorf("resolved %s", res.CommitID)
194195 }
195196 errs <- err
196197 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"))
198199 if err == nil {
199200 var body []byte
200201 body, err = io.ReadAll(rc)
@@ -205,13 +206,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
205206 }
206207 errs <- err
207208 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)
209210 if err == nil && len(tree.Entries) != 2 {
210211 err = fmt.Errorf("tree of %d entries", len(tree.Entries))
211212 }
212213 errs <- err
213214 default:
214- _, err := g.ResolveRefPath(ctx, h.name, "feature/x/d/f")
215+ _, err := g.ResolveRefPath(ctx, h.Name, "feature/x/d/f")
215216 errs <- err
216217 }
217218 }
@@ -227,7 +228,7 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
227228
228229 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
229230 g, data := newReader(t, nil)
230- h := newHistory(t, data)
231+ h := readertest.NewHistory(t, data)
231232
232233 // Many requests, some with a context that dies at a random point. Whatever
233234 // state a killed process was in, later requests must get right answers.
@@ -236,10 +237,10 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
236237 wg.Go(func() {
237238 for j := range 20 {
238239 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"))
240241 cancel()
241242 if err == nil {
242- assert.Equal(t, h.c2, res.CommitID.String())
243+ assert.Equal(t, h.C2, res.CommitID.String())
243244 } else {
244245 assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err)
245246 }
@@ -247,18 +248,18 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
247248 })
248249 }
249250 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"))
251252 require.NoError(t, err)
252- assert.Equal(t, h.c2, res.CommitID.String())
253+ assert.Equal(t, h.C2, res.CommitID.String())
253254 }
254255
255256 func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
256257 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")
259260
260261 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"))
262263 require.NoError(t, err)
263264 pids := g.BlobStreamPIDs()
264265 require.Len(t, pids, 1)
@@ -273,7 +274,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
273274 assert.Equal(t, 0, g.BlobStreams())
274275
275276 // 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"))
277278 require.NoError(t, err)
278279 pids = g.BlobStreamPIDs()
279280 require.Len(t, pids, 1)
@@ -284,7 +285,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
284285 // A context that is already cancelled starts nothing.
285286 cctx, cancel2 := context.WithCancel(context.Background())
286287 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"))
288289 assert.ErrorIs(t, err, context.Canceled)
289290 assert.Equal(t, 0, g.BlobStreams())
290291 }
@@ -299,18 +300,18 @@ func TestOpenBlobStreams50MiB(t *testing.T) {
299300 }
300301 ctx := context.Background()
301302 g, data := newReader(t, nil)
302- f := newFixtureRepo(t, data, "huge")
303+ f := readertest.NewFixture(t, data, "huge")
303304 const size = 50 << 20
304- content := randomBytes(size, 42)
305+ content := readertest.RandomBytes(size, 42)
305306 want := sha256.Sum256(content)
306- f.writeBytes("huge.bin", content).commit("huge")
307+ f.WriteBytes("huge.bin", content).Commit("huge")
307308 content = nil //nolint:ineffassign // let the 50 MiB go before measuring
308309
309310 runtime.GC()
310311 var before, after runtime.MemStats
311312 runtime.ReadMemStats(&before)
312313
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"))
314315 require.NoError(t, err)
315316 assert.Equal(t, int64(size), info.Size)
316317 assert.True(t, info.Binary)
@@ -383,10 +384,10 @@ func TestExecSemaphoreBoundsOneShotCommands(t *testing.T) {
383384 func TestBlobStreamsAreBounded(t *testing.T) {
384385 ctx := context.Background()
385386 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")
388389 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"))
390391 return rc, err
391392 }
392393
M internal/adapters/gitcli/read.go +2 -0
@@ -235,6 +235,8 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
235235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
236236 // first, with up to one `cat-file` request per candidate on the repository's
237237 // 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
238240 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
239241 dir, err := g.repoDir(repo)
240242 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 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package gitcli_test
3+package readertest
44
55 import (
66 "context"
@@ -18,74 +18,61 @@ import (
1818 "bnema.dev/ermite/internal/ports"
1919 )
2020
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 {
2256 t.Helper()
2357 r, err := domain.ParseRevision(s)
2458 require.NoError(t, err)
2559 return r
2660 }
2761
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 {
2964 t.Helper()
3065 p, err := domain.ParseTreePath(s)
3166 require.NoError(t, err)
3267 return p
3368 }
3469
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) {
8471 ctx := context.Background()
85- g, data := newReader(t, nil)
86- h := newHistory(t, data)
72+ g, data := mk(t)
73+ h := NewHistory(t, data)
8774
88- refs, err := g.ListRefs(ctx, h.name)
75+ refs, err := g.ListRefs(ctx, h.Name)
8976 require.NoError(t, err)
9077
9178 type row struct {
@@ -99,27 +86,27 @@ func TestListRefs(t *testing.T) {
9986 for _, r := range refs {
10087 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
10188 }
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) }
10390 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},
11097 {"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},
114101 }, got)
115102 }
116103
117-func TestListRefsEmptyAndMissing(t *testing.T) {
104+func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
118105 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")
121108
122- refs, err := g.ListRefs(ctx, f.name)
109+ refs, err := g.ListRefs(ctx, f.Name)
123110 require.NoError(t, err)
124111 require.Len(t, refs, 1)
125112 assert.Equal(t, "HEAD", refs[0].Name)
@@ -127,85 +114,85 @@ func TestListRefsEmptyAndMissing(t *testing.T) {
127114 assert.True(t, refs[0].ID.IsZero())
128115 assert.True(t, refs[0].CommitID.IsZero())
129116
130- _, err = g.ListRefs(ctx, name(t, "nope"))
117+ _, err = g.ListRefs(ctx, RepoName(t, "nope"))
131118 assert.ErrorIs(t, err, domain.ErrNotFound)
132119
133120 // 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{})
135122 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)
137124 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})
139126 assert.ErrorIs(t, err, domain.ErrNotFound)
140127 }
141128
142-func TestDetachedHead(t *testing.T) {
129+func testDetachedHead(t *testing.T, mk Maker) {
143130 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)
147134
148- refs, err := g.ListRefs(ctx, h.name)
135+ refs, err := g.ListRefs(ctx, h.Name)
149136 require.NoError(t, err)
150137 assert.Equal(t, "HEAD", refs[0].Name)
151138 assert.Empty(t, refs[0].ShortName)
152139 }
153140
154-func TestResolveRevision(t *testing.T) {
141+func testResolveRevision(t *testing.T, mk Maker) {
155142 ctx := context.Background()
156- g, data := newReader(t, nil)
157- h := newHistory(t, data)
143+ g, data := mk(t)
144+ h := NewHistory(t, data)
158145
159146 tests := []struct {
160147 rev string
161148 commit string
162149 ref string
163150 }{
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, ""},
176163 }
177164 for _, tt := range tests {
178165 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))
180167 require.NoError(t, err)
181168 assert.Equal(t, tt.commit, got.CommitID.String())
182169 assert.Equal(t, tt.ref, got.Ref)
183170 })
184171 }
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}")} {
186173 t.Run("missing "+bad, func(t *testing.T) {
187174 r, err := domain.ParseRevision(bad)
188175 if err != nil {
189176 return // not a revision at all, rejected before the reader
190177 }
191- _, err = g.ResolveRevision(ctx, h.name, r)
178+ _, err = g.ResolveRevision(ctx, h.Name, r)
192179 assert.ErrorIs(t, err, domain.ErrNotFound)
193180 })
194181 }
195182
196183 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"))
200187 require.NoError(t, err)
201- assert.Equal(t, h.c1, got.CommitID.String())
188+ assert.Equal(t, h.C1, got.CommitID.String())
202189 })
203190 }
204191
205-func TestResolveRefPath(t *testing.T) {
192+func testResolveRefPath(t *testing.T, mk Maker) {
206193 ctx := context.Background()
207- g, data := newReader(t, nil)
208- h := newHistory(t, data)
194+ g, data := mk(t)
195+ h := NewHistory(t, data)
209196
210197 tests := []struct {
211198 path string
@@ -214,22 +201,22 @@ func TestResolveRefPath(t *testing.T) {
214201 id string
215202 rest string
216203 }{
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"},
223210 // 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"},
229216 }
230217 for _, tt := range tests {
231218 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)
233220 require.NoError(t, err)
234221 assert.Equal(t, tt.name, got.Name)
235222 assert.Equal(t, tt.ref, got.Ref)
@@ -239,24 +226,24 @@ func TestResolveRefPath(t *testing.T) {
239226 }
240227 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
241228 t.Run("missing "+bad, func(t *testing.T) {
242- _, err := g.ResolveRefPath(ctx, h.name, bad)
229+ _, err := g.ResolveRefPath(ctx, h.Name, bad)
243230 assert.ErrorIs(t, err, domain.ErrNotFound)
244231 })
245232 }
246- _, err := g.ResolveRefPath(ctx, name(t, "nope"), "main")
233+ _, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main")
247234 assert.ErrorIs(t, err, domain.ErrNotFound)
248235 }
249236
250-func TestGetTree(t *testing.T) {
237+func testGetTree(t *testing.T, mk Maker) {
251238 ctx := context.Background()
252- g, data := newReader(t, nil)
253- h := newHistory(t, data)
239+ g, data := mk(t)
240+ h := NewHistory(t, data)
254241
255242 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)
257244 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())
260247 type row struct {
261248 name, path string
262249 kind domain.EntryKind
@@ -282,7 +269,7 @@ func TestGetTree(t *testing.T) {
282269 })
283270
284271 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)
286273 require.NoError(t, err)
287274 require.Len(t, tree.Entries, 2)
288275 assert.Equal(t, "sub", tree.Entries[0].Name)
@@ -290,42 +277,42 @@ func TestGetTree(t *testing.T) {
290277 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
291278 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
292279 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())
294281 })
295282
296283 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)
298285 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)
300287 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)
302289 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)
304291 assert.ErrorIs(t, err, domain.ErrNotFound)
305292 })
306293
307294 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)
309296 require.NoError(t, err)
310297 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)
312299 assert.ErrorIs(t, err, domain.ErrNotFound)
313300 })
314301 }
315302
316-func TestGetTreeLastCommit(t *testing.T) {
303+func testGetTreeLastCommit(t *testing.T, mk Maker) {
317304 ctx := context.Background()
318- g, data := newReader(t, nil)
319- h := newHistory(t, data)
305+ g, data := mk(t)
306+ h := NewHistory(t, data)
320307
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)
322309 require.NoError(t, err)
323310 got := map[string]string{}
324311 for _, e := range tree.Entries {
325312 require.NotNil(t, e.LastCommit, e.Path)
326313 got[e.Path] = e.LastCommit.Summary
327314 // 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)
329316 }
330317 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
331318 assert.Equal(t, "second", got["b.txt"])
@@ -335,17 +322,17 @@ func TestGetTreeLastCommit(t *testing.T) {
335322 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
336323 }
337324
338-func TestGetTreeLastCommitBound(t *testing.T) {
325+func testGetTreeLastCommitBound(t *testing.T, mk Maker) {
339326 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")
342329 n := domain.MaxLastCommitEntries + 5
343330 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")
345332 }
346- f.commit("wide")
333+ f.Commit("wide")
347334
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)
349336 require.NoError(t, err)
350337 require.Len(t, tree.Entries, n)
351338 assert.True(t, tree.LastCommitsOmitted)
@@ -359,7 +346,7 @@ func TestGetTreeLastCommitBound(t *testing.T) {
359346 assert.NotNil(t, tree.Entries[0].LastCommit)
360347 assert.Nil(t, tree.Entries[n-1].LastCommit)
361348
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)
363350 require.NoError(t, err)
364351 assert.False(t, tree.LastCommitsOmitted)
365352 }
@@ -375,18 +362,18 @@ func itoa(i int) string {
375362 return string(b)
376363 }
377364
378-func TestListCommits(t *testing.T) {
365+func testListCommits(t *testing.T, mk Maker) {
379366 ctx := context.Background()
380- g, data := newReader(t, nil)
381- h := newHistory(t, data)
367+ g, data := mk(t)
368+ h := NewHistory(t, data)
382369
383- oracle := strings.Split(h.git("log", "--format=%H", h.merge), "\n")
370+ oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n")
384371 require.Len(t, oracle, 6)
385372
386373 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})
388375 require.NoError(t, err)
389- assert.Equal(t, h.merge, page.Head.String())
376+ assert.Equal(t, h.Merge, page.Head.String())
390377 assert.False(t, page.HasMore)
391378 var ids []string
392379 for _, c := range page.Commits {
@@ -399,7 +386,7 @@ func TestListCommits(t *testing.T) {
399386 var ids []string
400387 skip := 0
401388 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})
403390 require.NoError(t, err)
404391 for _, c := range page.Commits {
405392 ids = append(ids, c.ID.String())
@@ -414,33 +401,33 @@ func TestListCommits(t *testing.T) {
414401 })
415402
416403 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})
418405 require.NoError(t, err)
419406 var ids []string
420407 for _, c := range page.Commits {
421408 ids = append(ids, c.ID.String())
422409 }
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)
424411 assert.Len(t, ids, 2)
425412
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})
427414 require.NoError(t, err)
428415 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})
430417 require.NoError(t, err)
431418 assert.Empty(t, page.Commits)
432419 })
433420
434421 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})
436423 require.NoError(t, err)
437424 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())
440427 })
441428
442429 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})
444431 require.NoError(t, err)
445432 require.Len(t, page.Commits, 1)
446433 c := page.Commits[0]
@@ -451,25 +438,26 @@ func TestListCommits(t *testing.T) {
451438 assert.Equal(t, "Grace Hopper", c.Committer.Name)
452439 assert.Equal(t, "grace@example.org", c.Committer.Email)
453440
454- page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 1})
441+ page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1})
455442 require.NoError(t, err)
456443 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))
458445 })
459446
460447 t.Run("bad queries", func(t *testing.T) {
461448 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)
463450 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
464451 }
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})
466453 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})
468455 assert.ErrorIs(t, err, domain.ErrNotFound)
469456 })
470457 }
471458
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 {
473461 t.Helper()
474462 id, err := domain.ParseObjectID(s)
475463 require.NoError(t, err)
@@ -484,14 +472,14 @@ func idStrings(ids []domain.ObjectID) []string {
484472 return out
485473 }
486474
487-func TestOpenBlob(t *testing.T) {
475+func testOpenBlob(t *testing.T, mk Maker) {
488476 ctx := context.Background()
489- g, data := newReader(t, nil)
490- h := newHistory(t, data)
477+ g, data := mk(t)
478+ h := NewHistory(t, data)
491479
492480 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
493481 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))
495483 require.NoError(t, err)
496484 body, err := io.ReadAll(rc)
497485 require.NoError(t, err)
@@ -506,7 +494,7 @@ func TestOpenBlob(t *testing.T) {
506494 assert.Equal(t, uint32(0o100644), info.Mode)
507495 assert.False(t, info.Binary)
508496 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())
510498
511499 info, body = read(t, "main", "bin")
512500 assert.Equal(t, "\x00\x02", body)
@@ -529,30 +517,30 @@ func TestOpenBlob(t *testing.T) {
529517 assert.Equal(t, "text/plain", info.MediaType)
530518
531519 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))
533521 assert.ErrorIs(t, err, want, p)
534522 assert.Nil(t, rc)
535523 }
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"))
537525 assert.ErrorIs(t, err, domain.ErrNotFound)
538526 }
539527
540-func TestOpenBlobMediaTypes(t *testing.T) {
528+func testOpenBlobMediaTypes(t *testing.T, mk Maker) {
541529 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")
544532 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")
556544
557545 want := map[string]struct {
558546 media string
@@ -570,7 +558,7 @@ func TestOpenBlobMediaTypes(t *testing.T) {
570558 "latin": {"text/plain", false},
571559 }
572560 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))
574562 require.NoError(t, err, p)
575563 require.NoError(t, rc.Close())
576564 assert.Equal(t, w.media, info.MediaType, p)
@@ -578,69 +566,67 @@ func TestOpenBlobMediaTypes(t *testing.T) {
578566 }
579567 }
580568
581-func TestBinaryRuleLooksAtFirst8000Bytes(t *testing.T) {
569+func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) {
582570 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")
585573 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
586574 early := append([]byte(strings.Repeat("a", 7999)), 0)
587- f.writeBytes("late", late).writeBytes("early", early)
575+ f.WriteBytes("late", late).WriteBytes("early", early)
588576 // 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")
592580
593581 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))
595583 require.NoError(t, err, p)
596584 require.NoError(t, rc.Close())
597585 assert.Equal(t, binary, info.Binary, p)
598586 }
599587 }
600588
601-func TestOpenBlobLargeThroughStream(t *testing.T) {
589+func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) {
602590 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")
607595
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"))
609597 require.NoError(t, err)
610598 assert.Equal(t, int64(len(content)), info.Size)
611- assert.Equal(t, 1, g.BlobStreams())
612599 got, err := io.ReadAll(rc)
613600 require.NoError(t, err)
614601 assert.Equal(t, content, got)
615602 require.NoError(t, rc.Close())
616603 require.NoError(t, rc.Close(), "Close is idempotent")
617- assert.Equal(t, 0, g.BlobStreams())
618604 }
619605
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)
623609
624610 ctx, cancel := context.WithCancel(context.Background())
625611 cancel()
626- _, err := g.ListRefs(ctx, h.name)
612+ _, err := g.ListRefs(ctx, h.Name)
627613 assert.ErrorIs(t, err, context.Canceled)
628- _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})
614+ _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
629615 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())
631617 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})
633619 assert.ErrorIs(t, err, context.Canceled)
634620
635621 // 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{})
637623 assert.NoError(t, err)
638624 }
639625
640-func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
626+func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) {
641627 ctx := context.Background()
642- g, data := newReader(t, nil)
643- h := newHistory(t, data)
628+ g, data := mk(t)
629+ h := NewHistory(t, data)
644630
645631 // Revisions that look like options or git revision syntax are rejected by
646632 // the domain; ones that pass it are plain names for the reader.
@@ -648,9 +634,9 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
648634 _, err := domain.ParseRevision(s)
649635 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
650636 }
651- _, err := g.ResolveRevision(ctx, h.name, rev(t, "HEAD.x"))
637+ _, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x"))
652638 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)
654640 assert.ErrorIs(t, err, domain.ErrNotFound)
655641 var pe *domain.InvalidNameError
656642 assert.False(t, errors.As(err, &pe))
@@ -658,60 +644,60 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
658644
659645 // A full object id is an object id before it is a ref: a branch or a tag
660646 // 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) {
662648 ctx := context.Background()
663- g, data := newReader(t, nil)
664- h := newHistory(t, data)
649+ g, data := mk(t)
650+ h := NewHistory(t, data)
665651
666652 // 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)
669655
670- got, err := g.ResolveRevision(ctx, h.name, rev(t, h.c1))
656+ got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1))
671657 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")
673659 assert.Empty(t, got.Ref)
674660
675- got, err = g.ResolveRevision(ctx, h.name, rev(t, h.c2))
661+ got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2))
676662 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")
678664
679665 // 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"))
683669 require.NoError(t, err)
684- assert.Equal(t, h.c3, got.CommitID.String())
670+ assert.Equal(t, h.C3, got.CommitID.String())
685671 assert.Equal(t, "refs/heads/abc1234", got.Ref)
686672 }
687673
688674 // The ref is used when no object has that id.
689675 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))
692678 require.NoError(t, err)
693- assert.Equal(t, h.c3, got.CommitID.String())
679+ assert.Equal(t, h.C3, got.CommitID.String())
694680 assert.Equal(t, "refs/heads/"+ghost, got.Ref)
695681 }
696682
697-func TestGetTreeIsBounded(t *testing.T) {
683+func testGetTreeIsBounded(t *testing.T, mk Maker) {
698684 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")
701687 n := domain.MaxTreeEntries + 7
702688 for i := range n {
703- f.write(fmt.Sprintf("d/f%05d", i), "x")
689+ f.Write(fmt.Sprintf("d/f%05d", i), "x")
704690 }
705- f.commit("wide")
691+ f.Commit("wide")
706692
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)
708694 require.NoError(t, err)
709695 assert.True(t, tree.Truncated)
710696 require.Len(t, tree.Entries, domain.MaxTreeEntries)
711697 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
712698 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
713699
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)
715701 require.NoError(t, err)
716702 assert.False(t, small.Truncated)
717703 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close