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 (
13 13
14 require (14 require (
15 bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed15 bnema.dev/ermite-api v0.0.0-20261010155050-303f037eb5ed
16+ bnema.dev/ermite-git v0.0.0-20261010222615-ea9bcf1dbea0
16 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c317 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
17 connectrpc.com/connect v1.21.018 connectrpc.com/connect v1.21.0
18 github.com/a-h/templ v0.3.102019 github.com/a-h/templ v0.3.1020
M go.sum +2 -0 generated file: collapsed
A internal/adapters/ermitegit/bench_test.go +255 -0
@@ -0,0 +1,255 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "context"
7+ "fmt"
8+ "io"
9+ "io/fs"
10+ "os"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/adapters/ermitegit"
20+ "bnema.dev/ermite/internal/adapters/gitcli"
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/readertest"
24+)
25+
26+// benchTarget is what the benchmarks ask about: a repository and the names in
27+// it, found with git.
28+type benchTarget struct {
29+ name domain.RepoName
30+ dir string
31+ branch string // a branch name
32+ head string // the full id of HEAD
33+ abbrev string // an abbreviated commit id
34+ refPath string // "branch/dir/file"
35+ treeDir string // a directory with many entries
36+ file string // a text file of some KiB
37+ big string // the largest blob of HEAD
38+ history string // a path with a long history
39+ deepSkip int // a page far down the history
40+ commitsAll int
41+}
42+
43+// newBenchTarget builds the fixture repository, or clones EnvBenchRepo when it
44+// is set, and looks for the targets with git.
45+func newBenchTarget(b *testing.B, g *gitcli.Git, data string) benchTarget {
46+ b.Helper()
47+ var t benchTarget
48+ if os.Getenv(EnvBenchRepo) != "" {
49+ r := cloneBench(b, g, data)
50+ t.name, t.dir = r.name, r.dir
51+ } else {
52+ f := benchFixture(b, data)
53+ t.name, t.dir = f.Name, f.Bare
54+ f.Maintain()
55+ }
56+ ageFiles(b, t.dir)
57+ git := func(args ...string) string { return gitOutB(b, t.dir, args...) }
58+ t.branch = git("symbolic-ref", "--short", "HEAD")
59+ head := git("rev-parse", "HEAD")
60+ t.head, t.abbrev = head, head[:10]
61+ t.commitsAll, _ = strconv.Atoi(git("rev-list", "--count", "HEAD"))
62+ t.deepSkip = min(5000, t.commitsAll*2/3)
63+
64+ var bigSize int64
65+ dirCount := map[string]int{}
66+ var files []string
67+ for _, l := range strings.Split(git("ls-tree", "-r", "-l", "HEAD"), "\n") {
68+ meta, p, ok := strings.Cut(l, "\t")
69+ f := strings.Fields(meta)
70+ if !ok || len(f) != 4 || f[1] != "blob" {
71+ continue
72+ }
73+ size, _ := strconv.ParseInt(f[3], 10, 64)
74+ if size > bigSize {
75+ bigSize, t.big = size, p
76+ }
77+ if i := strings.LastIndexByte(p, '/'); i >= 0 {
78+ dirCount[p[:i]]++
79+ }
80+ if size >= 2<<10 && size <= 100<<10 && strings.Count(p, "/") <= 2 && !strings.ContainsAny(p, " *") {
81+ files = append(files, p)
82+ }
83+ }
84+ for d, n := range dirCount {
85+ if n > dirCount[t.treeDir] || (n == dirCount[t.treeDir] && d < t.treeDir) {
86+ t.treeDir = d
87+ }
88+ }
89+ require.NotEmpty(b, files, "no text file to read")
90+ t.file = files[len(files)/2]
91+ t.history = t.file
92+ t.refPath = t.branch + "/" + t.file
93+ return t
94+}
95+
96+// BenchmarkRepoReader compares the CLI reader and the ermite-git adapter,
97+// method by method, on a generated repository or, when ERMITE_GIT_BENCH_REPO
98+// is set, on a clone of that repository:
99+//
100+// go test -run '^$' -bench RepoReader -benchmem ./internal/adapters/ermitegit
101+// ERMITE_GIT_BENCH_REPO=/path/to/repo/.git go test -run '^$' -bench RepoReader -benchmem ...
102+func BenchmarkRepoReader(b *testing.B) {
103+ a, g, data := newAdapter(b, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
104+ t := newBenchTarget(b, g, data)
105+ b.Logf("repository: %d commits, branch %s, dir %s, file %s, big %s, deep skip %d", t.commitsAll, t.branch, t.treeDir, t.file, t.big, t.deepSkip)
106+ ctx := context.Background()
107+ rev := func(s string) domain.Revision {
108+ r, err := domain.ParseRevision(s)
109+ require.NoError(b, err)
110+ return r
111+ }
112+ path := func(s string) domain.TreePath {
113+ p, err := domain.ParseTreePath(s)
114+ require.NoError(b, err)
115+ return p
116+ }
117+ drain := func(r ports.RepoReader, v domain.Revision, p domain.TreePath) error {
118+ _, rc, err := r.OpenBlob(ctx, t.name, v, p)
119+ if err != nil {
120+ return err
121+ }
122+ _, err = io.Copy(io.Discard, rc)
123+ _ = rc.Close()
124+ return err
125+ }
126+ // ermite-git re-reads a commit-graph it loaded less than two seconds ago on
127+ // every Refresh (see ageFiles). Open the repository and let that window
128+ // pass, as a long-lived server would have.
129+ require.NoError(b, drain(a, domain.Revision{}, path(t.file)))
130+ time.Sleep(2100 * time.Millisecond)
131+ type method struct {
132+ name string
133+ run func(r ports.RepoReader) error
134+ }
135+ methods := []method{
136+ {"ListRefs", func(r ports.RepoReader) (err error) { _, err = r.ListRefs(ctx, t.name); return }},
137+ {"ResolveRevision/branch", func(r ports.RepoReader) (err error) {
138+ _, err = r.ResolveRevision(ctx, t.name, rev(t.branch))
139+ return
140+ }},
141+ {"ResolveRevision/fullid", func(r ports.RepoReader) (err error) {
142+ _, err = r.ResolveRevision(ctx, t.name, rev(t.head))
143+ return
144+ }},
145+ {"ResolveRevision/abbrev", func(r ports.RepoReader) (err error) {
146+ _, err = r.ResolveRevision(ctx, t.name, rev(t.abbrev))
147+ return
148+ }},
149+ {"ResolveRefPath", func(r ports.RepoReader) (err error) { _, err = r.ResolveRefPath(ctx, t.name, t.refPath); return }},
150+ {"GetTree/root", func(r ports.RepoReader) (err error) {
151+ _, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, false)
152+ return
153+ }},
154+ {"GetTree/dir", func(r ports.RepoReader) (err error) {
155+ _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), false)
156+ return
157+ }},
158+ {"GetTree/root+last", func(r ports.RepoReader) (err error) {
159+ _, err = r.GetTree(ctx, t.name, domain.Revision{}, domain.TreePath{}, true)
160+ return
161+ }},
162+ {"GetTree/dir+last", func(r ports.RepoReader) (err error) {
163+ _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164+ return
165+ }},
166+ {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167+ {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168+ {"ListCommits/page1", func(r ports.RepoReader) (err error) {
169+ _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Limit: 30})
170+ return
171+ }},
172+ {"ListCommits/deep", func(r ports.RepoReader) (err error) {
173+ _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Skip: t.deepSkip, Limit: 30})
174+ return
175+ }},
176+ {"ListCommits/path", func(r ports.RepoReader) (err error) {
177+ _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.history), Limit: 30})
178+ return
179+ }},
180+ {"ListCommits/dirpath", func(r ports.RepoReader) (err error) {
181+ _, err = r.ListCommits(ctx, t.name, ports.CommitQuery{Path: path(t.treeDir), Limit: 30})
182+ return
183+ }},
184+ }
185+ for _, m := range methods {
186+ b.Run(m.name, func(b *testing.B) {
187+ for _, ad := range []struct {
188+ name string
189+ r ports.RepoReader
190+ }{{"cli", g}, {"ermitegit", a}} {
191+ b.Run(ad.name, func(b *testing.B) {
192+ require.NoError(b, m.run(ad.r)) // warm-up: processes, caches, graph
193+ b.ReportAllocs()
194+ b.ResetTimer()
195+ for b.Loop() {
196+ if err := m.run(ad.r); err != nil {
197+ b.Fatal(err)
198+ }
199+ }
200+ })
201+ }
202+ })
203+ }
204+}
205+
206+// benchFixture builds the generated repository: a few hundred commits over a
207+// tree of directories, merges, tags, and a 10 MiB blob.
208+func benchFixture(b *testing.B, data string) *readertest.Fixture {
209+ b.Helper()
210+ f := readertest.NewFixture(b, data, "bench")
211+ f.Git("config", "commitGraph.changedPathsVersion", "2")
212+ f.Git("config", "core.compression", "1")
213+ for i := range 200 {
214+ for j := range 3 {
215+ f.Write(fmt.Sprintf("pkg%d/file%02d.go", (i+j)%8, (i*3+j)%40), strings.Repeat(fmt.Sprintf("// version %d of this file\n", i), 160))
216+ }
217+ f.Write("README.md", fmt.Sprintf("# project\n\nrevision %d\n", i))
218+ f.Commit(fmt.Sprintf("change %d\n\nbody of the message", i))
219+ if i%25 == 24 {
220+ f.CheckoutNew(fmt.Sprintf("topic%d", i))
221+ f.Write(fmt.Sprintf("topics/topic%d.txt", i), "t\n")
222+ f.Commit("topic work")
223+ f.Checkout("main")
224+ f.Write("pkg0/main-work.go", fmt.Sprintf("// main work %d\n", i))
225+ f.Commit("main work")
226+ f.Merge(fmt.Sprintf("topic%d", i))
227+ f.Git("tag", "-a", "-m", "release", fmt.Sprintf("v%d", i/25))
228+ }
229+ }
230+ f.WriteBytes("assets/big.bin", readertest.RandomBytes(10<<20, 5))
231+ f.Commit("add a 10 MiB asset")
232+ f.Git("gc", "--quiet")
233+ return f
234+}
235+
236+// ageFiles sets the modification time of every file of the repository to an
237+// hour ago. ermite-git re-reads the files modified in the last two seconds (the
238+// racy window of git's index rule): a repository that was just built would
239+// measure that path. A repository that serves reads is not in that state, its
240+// last push is older.
241+func ageFiles(b *testing.B, dir string) {
242+ b.Helper()
243+ old := time.Now().Add(-time.Hour)
244+ require.NoError(b, filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
245+ if err != nil {
246+ return err
247+ }
248+ return os.Chtimes(p, old, old)
249+ }))
250+}
251+
252+func gitOutB(b *testing.B, dir string, args ...string) string {
253+ b.Helper()
254+ return readertest.GitOut(b, dir, args...)
255+}
A internal/adapters/ermitegit/bigrepo_test.go +57 -0
@@ -0,0 +1,57 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "testing"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/adapters/gitcli"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// EnvBenchRepo names a real repository (its .git directory) for the
19+// comparison test and the benchmarks, which skip without it. It is never
20+// modified: it is cloned.
21+const EnvBenchRepo = "ERMITE_GIT_BENCH_REPO"
22+
23+// bigRepo is a bare clone of the repository of EnvBenchRepo, configured like
24+// a repository ermite creates and maintained the way ermite does.
25+type bigRepo struct {
26+ name domain.RepoName
27+ dir string
28+}
29+
30+// cloneBench clones the repository of EnvBenchRepo into the data directory with
31+// --no-local (a real pack transfer, so that the clone is packed as a push
32+// would leave it), applies the settings of gitcli.Stage and runs
33+// gitcli.Maintain. It skips the test when the variable is unset.
34+func cloneBench(tb testing.TB, g *gitcli.Git, data string) bigRepo {
35+ tb.Helper()
36+ src := os.Getenv(EnvBenchRepo)
37+ if src == "" {
38+ tb.Skipf("%s is not set", EnvBenchRepo)
39+ }
40+ name, err := domain.ParseRepoName("bench")
41+ require.NoError(tb, err)
42+ dir := gitcli.RepoDir(data, name)
43+ run := func(args ...string) {
44+ tb.Helper()
45+ cmd := exec.Command("git", args...)
46+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(data, "run", "git-home"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
47+ out, err := cmd.CombinedOutput()
48+ require.NoError(tb, err, "git %v: %s", args, out)
49+ }
50+ run("clone", "--quiet", "--bare", "--no-local", src, dir)
51+ for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} {
52+ run("-C", dir, "config", "--local", kv[0], kv[1])
53+ }
54+ run("-C", dir, "gc", "--quiet", "--prune=now")
55+ require.NoError(tb, g.Maintain(context.Background(), name))
56+ return bigRepo{name: name, dir: dir}
57+}
A internal/adapters/ermitegit/cache.go +305 -0
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+import (
6+ "container/list"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io/fs"
11+ "os"
12+ "sync"
13+ "sync/atomic"
14+ "syscall"
15+ "time"
16+
17+ git "bnema.dev/ermite-git"
18+
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// DefaultMaxRepos is the number of repositories kept open when Config.MaxRepos
23+// is zero.
24+const DefaultMaxRepos = 64
25+
26+// DefaultRefreshEvery is how long a repository goes without a Refresh when
27+// Config.RefreshEvery is zero. See Config.RefreshEvery for what depends on it.
28+const DefaultRefreshEvery = time.Second
29+
30+// unsupportedRetry is how long a repository that ermite-git cannot read is
31+// served by git before Open is tried again (a repack, or the removal of a
32+// replace ref, can make it readable).
33+const unsupportedRetry = time.Minute
34+
35+// entry is the state of one repository directory in the cache: an open
36+// *git.Repo, or the reason it is not readable by ermite-git.
37+//
38+// The fields below the line belong to the cache and are guarded by its mutex.
39+type entry struct {
40+ dir string
41+ ino uint64 // of the directory: a new repository under the same name differs
42+ dev uint64
43+ once sync.Once
44+ // opened is the result of git.Open, published by the Once; nil before.
45+ opened atomic.Pointer[opened]
46+
47+ refreshed atomic.Int64 // unix nanoseconds of the last Refresh
48+
49+ elem *list.Element
50+ users int
51+ dropped bool // out of the cache: close when the last user is done
52+}
53+
54+// opened is what git.Open answered for an entry.
55+type opened struct {
56+ repo *git.Repo // nil when unsupported
57+ err error // Open failed with something else than ErrUnsupported
58+ at time.Time
59+}
60+
61+// handle is a use of an entry; release it when the read is over. repo is nil
62+// when git must serve the read.
63+type handle struct {
64+ c *cache
65+ e *entry
66+ repo *git.Repo
67+}
68+
69+func (h *handle) release() { h.c.release(h.e) }
70+
71+// cache is a bounded LRU of open repositories keyed by directory. A repository
72+// leaves it by eviction, by Forget (the directory was deleted) or because the
73+// directory is no longer the same one (checked on each use through its
74+// inode); it is closed once the reads that use it are done.
75+type cache struct {
76+ max int
77+ refreshEvery time.Duration
78+ onUnsupport func(ctx context.Context, dir string, name domain.RepoName, cause error)
79+
80+ mu sync.Mutex
81+ entries map[string]*entry
82+ lru *list.List // front = most recently used
83+ closed bool
84+}
85+
86+func newCache(max int, refreshEvery time.Duration, onUnsupport func(context.Context, string, domain.RepoName, error)) *cache {
87+ if max <= 0 {
88+ max = DefaultMaxRepos
89+ }
90+ return &cache{max: max, refreshEvery: refreshEvery, onUnsupport: onUnsupport, entries: make(map[string]*entry), lru: list.New()}
91+}
92+
93+// get returns the entry of dir, opened and refreshed, or a nil repo when
94+// ermite-git does not read the repository. A missing directory is
95+// domain.ErrNotFound.
96+func (c *cache) get(ctx context.Context, name domain.RepoName, dir string) (*handle, error) {
97+ fi, err := os.Stat(dir)
98+ if err != nil {
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return nil, domain.ErrNotFound
101+ }
102+ return nil, fmt.Errorf("repository %q: %w", name, err)
103+ }
104+ if !fi.IsDir() {
105+ return nil, domain.ErrNotFound
106+ }
107+ dev, ino := identity(fi)
108+
109+ c.mu.Lock()
110+ if c.closed {
111+ c.mu.Unlock()
112+ return nil, ErrClosed
113+ }
114+ e := c.entries[dir]
115+ if e != nil && (e.ino != ino || e.dev != dev || e.stale()) {
116+ c.dropLocked(e)
117+ e = nil
118+ }
119+ if e == nil {
120+ e = &entry{dir: dir, ino: ino, dev: dev}
121+ e.elem = c.lru.PushFront(e)
122+ c.entries[dir] = e
123+ c.evictLocked()
124+ } else {
125+ c.lru.MoveToFront(e.elem)
126+ }
127+ e.users++
128+ c.mu.Unlock()
129+
130+ h := &handle{c: c, e: e}
131+ e.once.Do(func() { c.open(ctx, name, e) })
132+ op := e.opened.Load()
133+ if op.err != nil {
134+ c.forgetEntry(e) // the next read tries again
135+ h.release()
136+ return nil, fmt.Errorf("open repository %q: %w", name, mapOpenErr(op.err))
137+ }
138+ if op.repo == nil {
139+ return h, nil // unsupported
140+ }
141+ if err := c.refresh(e, op.repo); err != nil {
142+ if errors.Is(err, git.ErrUnsupported) {
143+ h.fallBack(ctx, name, err)
144+ return h, nil
145+ }
146+ h.release()
147+ return nil, fmt.Errorf("refresh repository %q: %w", name, err)
148+ }
149+ h.repo = op.repo
150+ return h, nil
151+}
152+
153+// refresh calls Refresh on the repository of e unless one ran less than
154+// refreshEvery ago (a negative interval refreshes on every read). The library
155+// reads loose refs live, stat-checks packed-refs on each ref read and rescans
156+// the packs when an object is missing, so a read sees a push whether or not
157+// Refresh ran; Refresh is what drops replaced packs, picks up a new
158+// commit-graph and notices that the repository became unsupported.
159+func (c *cache) refresh(e *entry, repo *git.Repo) error {
160+ now := time.Now().UnixNano()
161+ if c.refreshEvery >= 0 && now-e.refreshed.Load() < int64(c.refreshEvery) {
162+ return nil
163+ }
164+ if err := repo.Refresh(); err != nil {
165+ return err
166+ }
167+ e.refreshed.Store(now)
168+ return nil
169+}
170+
171+// stale reports whether a repository that ermite-git did not read is due for
172+// another try. The caller holds the cache mutex.
173+func (e *entry) stale() bool {
174+ op := e.opened.Load()
175+ return op != nil && op.repo == nil && op.err == nil && time.Since(op.at) > unsupportedRetry
176+}
177+
178+// open runs git.Open once per entry; concurrent users wait in Once.Do.
179+func (c *cache) open(ctx context.Context, name domain.RepoName, e *entry) {
180+ op := &opened{at: time.Now()}
181+ repo, err := git.Open(e.dir)
182+ switch {
183+ case err == nil:
184+ op.repo = repo
185+ case errors.Is(err, git.ErrUnsupported):
186+ c.onUnsupport(ctx, e.dir, name, err)
187+ default:
188+ op.err = err
189+ }
190+ e.opened.Store(op)
191+}
192+
193+// mapOpenErr maps the failure of git.Open: a directory that is not a
194+// repository is a missing repository.
195+func mapOpenErr(err error) error {
196+ if errors.Is(err, git.ErrNotFound) {
197+ return fmt.Errorf("%w: %w", domain.ErrNotFound, err)
198+ }
199+ return err
200+}
201+
202+// markUnsupported turns a repository that stopped being readable (a replace
203+// ref was pushed, say) into an unsupported one: git serves it from now on.
204+func (c *cache) markUnsupported(ctx context.Context, name domain.RepoName, e *entry, cause error) {
205+ c.onUnsupport(ctx, e.dir, name, cause)
206+ c.mu.Lock()
207+ defer c.mu.Unlock()
208+ if c.entries[e.dir] == e {
209+ c.dropLocked(e)
210+ n := &entry{dir: e.dir, ino: e.ino, dev: e.dev}
211+ n.once.Do(func() { n.opened.Store(&opened{at: time.Now()}) }) // no repo, no error: unsupported
212+ n.elem = c.lru.PushFront(n)
213+ c.entries[e.dir] = n
214+ }
215+}
216+
217+// fallBack records that the repository stopped being readable by ermite-git
218+// (a replace ref was pushed, a feature appeared midway): git serves it from
219+// now on, and this read too.
220+func (h *handle) fallBack(ctx context.Context, name domain.RepoName, cause error) {
221+ h.repo = nil
222+ h.c.markUnsupported(ctx, name, h.e, cause)
223+}
224+
225+func (c *cache) release(e *entry) {
226+ c.mu.Lock()
227+ e.users--
228+ closeNow := e.dropped && e.users == 0
229+ c.mu.Unlock()
230+ if closeNow {
231+ e.closeRepo()
232+ }
233+}
234+
235+// closeRepo closes the repository of an entry that left the cache and has no
236+// user. An entry whose open has not finished has no repository yet: its last
237+// user closes it.
238+func (e *entry) closeRepo() {
239+ if op := e.opened.Load(); op != nil && op.repo != nil {
240+ _ = op.repo.Close()
241+ }
242+}
243+
244+// dropLocked removes e from the cache. The caller holds the mutex. The
245+// repository is closed by the last user (or by the caller if it has none).
246+func (c *cache) dropLocked(e *entry) {
247+ if e.dropped {
248+ return
249+ }
250+ e.dropped = true
251+ c.lru.Remove(e.elem)
252+ delete(c.entries, e.dir)
253+ if e.users == 0 {
254+ go e.closeRepo() // not under the mutex: Close takes the repo's own
255+ }
256+}
257+
258+// evictLocked drops the least recently used entries beyond the bound.
259+func (c *cache) evictLocked() {
260+ for c.lru.Len() > c.max {
261+ c.dropLocked(c.lru.Back().Value.(*entry))
262+ }
263+}
264+
265+// forgetEntry drops e if it is still the entry of its directory.
266+func (c *cache) forgetEntry(e *entry) {
267+ c.mu.Lock()
268+ defer c.mu.Unlock()
269+ if c.entries[e.dir] == e {
270+ c.dropLocked(e)
271+ }
272+}
273+
274+// forget drops the entry of dir.
275+func (c *cache) forget(dir string) {
276+ c.mu.Lock()
277+ defer c.mu.Unlock()
278+ if e := c.entries[dir]; e != nil {
279+ c.dropLocked(e)
280+ }
281+}
282+
283+// close drops every entry; later gets fail with ErrClosed.
284+func (c *cache) close() {
285+ c.mu.Lock()
286+ defer c.mu.Unlock()
287+ c.closed = true
288+ for _, e := range c.entries {
289+ c.dropLocked(e)
290+ }
291+}
292+
293+// size returns the number of cached repositories.
294+func (c *cache) size() int {
295+ c.mu.Lock()
296+ defer c.mu.Unlock()
297+ return len(c.entries)
298+}
299+
300+func identity(fi fs.FileInfo) (dev, ino uint64) {
301+ if st, ok := fi.Sys().(*syscall.Stat_t); ok {
302+ return uint64(st.Dev), st.Ino //nolint:unconvert // Dev is uint32 on some platforms
303+ }
304+ return 0, 0
305+}
A internal/adapters/ermitegit/cache_test.go +206 -0
@@ -0,0 +1,206 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/adapters/ermitegit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/readertest"
22+)
23+
24+func commitOf(t *testing.T, data, repo string) *readertest.Fixture {
25+ t.Helper()
26+ f := readertest.NewFixture(t, data, repo)
27+ f.Write("a.txt", repo+"\n")
28+ f.Commit("commit of " + repo)
29+ return f
30+}
31+
32+func TestCacheIsBoundedAndEvictsLeastRecentlyUsed(t *testing.T) {
33+ ctx := context.Background()
34+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 3 })
35+ var fx []*readertest.Fixture
36+ for i := range 6 {
37+ fx = append(fx, commitOf(t, data, fmt.Sprintf("r%d", i)))
38+ }
39+ for _, f := range fx {
40+ res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{})
41+ require.NoError(t, err)
42+ require.Equal(t, f.Git("rev-parse", "HEAD"), res.CommitID.String())
43+ assert.LessOrEqual(t, a.OpenRepos(), 3)
44+ }
45+ assert.Equal(t, 3, a.OpenRepos())
46+ // An evicted repository is opened again on the next read.
47+ res, err := a.ResolveRevision(ctx, fx[0].Name, domain.Revision{})
48+ require.NoError(t, err)
49+ assert.Equal(t, fx[0].Git("rev-parse", "HEAD"), res.CommitID.String())
50+ assert.Equal(t, 3, a.OpenRepos())
51+}
52+
53+// An evicted repository stays usable by the reads that hold it: a blob being
54+// streamed is not cut when the cache closes its repository.
55+func TestEvictionDoesNotCutAStream(t *testing.T) {
56+ ctx := context.Background()
57+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 1 })
58+ f := readertest.NewFixture(t, data, "big")
59+ content := readertest.RandomBytes(3<<20, 11)
60+ f.WriteBytes("big.bin", content)
61+ f.Commit("big")
62+ other := commitOf(t, data, "other")
63+
64+ _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
65+ require.NoError(t, err)
66+ head := make([]byte, 1000)
67+ _, err = rc.Read(head)
68+ require.NoError(t, err)
69+
70+ _, err = a.ResolveRevision(ctx, other.Name, domain.Revision{}) // evicts "big"
71+ require.NoError(t, err)
72+ assert.Equal(t, 1, a.OpenRepos())
73+
74+ rest := new(bytes.Buffer)
75+ _, err = rest.ReadFrom(rc)
76+ require.NoError(t, err)
77+ require.NoError(t, rc.Close())
78+ assert.True(t, bytes.Equal(content[1000:], rest.Bytes()))
79+}
80+
81+func TestConcurrentReadsEvictionAndPushes(t *testing.T) {
82+ ctx := context.Background()
83+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.MaxRepos = 2; c.RefreshEvery = -1 })
84+ var fx []*readertest.Fixture
85+ for i := range 5 {
86+ fx = append(fx, commitOf(t, data, fmt.Sprintf("c%d", i)))
87+ }
88+ var wg sync.WaitGroup
89+ for w := range 8 {
90+ wg.Go(func() {
91+ for i := range 40 {
92+ f := fx[(w+i)%len(fx)]
93+ if _, err := a.ListRefs(ctx, f.Name); err != nil {
94+ t.Error(err)
95+ return
96+ }
97+ page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
98+ if err != nil || len(page.Commits) != 1 {
99+ t.Errorf("%v %v", err, page)
100+ return
101+ }
102+ if _, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true); err != nil {
103+ t.Error(err)
104+ return
105+ }
106+ }
107+ })
108+ }
109+ wg.Wait()
110+ a.Close()
111+ _, err := a.ListRefs(ctx, fx[0].Name)
112+ assert.ErrorIs(t, err, ermitegit.ErrClosed)
113+}
114+
115+// A repository ermite-git rejects is served by git for every method, and the
116+// reason is logged once at info.
117+func TestUnsupportedRepositoryFallsBackToGit(t *testing.T) {
118+ var logs bytes.Buffer
119+ log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs})
120+ ctx := zerowrap.WithCtx(context.Background(), log)
121+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
122+
123+ h := readertest.NewHistory(t, data)
124+ // An alternates file makes the repository unsupported for the library.
125+ other := t.TempDir()
126+ require.NoError(t, os.WriteFile(filepath.Join(h.Bare, "objects", "info", "alternates"), []byte(filepath.Join(other, "objects")+"\n"), 0o644))
127+
128+ for range 3 {
129+ want, err := g.ListRefs(ctx, h.Name)
130+ require.NoError(t, err)
131+ got, err := a.ListRefs(ctx, h.Name)
132+ require.NoError(t, err)
133+ assert.Equal(t, want, got)
134+ page, err := a.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
135+ require.NoError(t, err)
136+ assert.Len(t, page.Commits, 6)
137+ tree, err := a.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, true)
138+ require.NoError(t, err)
139+ assert.NotEmpty(t, tree.Entries)
140+ }
141+ assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("served by git")), "logged once: %s", logs.String())
142+ assert.Contains(t, logs.String(), `"level":"info"`)
143+}
144+
145+// A repository that becomes unsupported while it is open (a replace ref is
146+// pushed) is handed to git from then on.
147+func TestRepositoryBecomingUnsupportedFallsBack(t *testing.T) {
148+ ctx := context.Background()
149+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = -1 })
150+ h := readertest.NewHistory(t, data)
151+ _, err := a.ListRefs(ctx, h.Name)
152+ require.NoError(t, err)
153+
154+ h.Git("replace", h.C2, h.C3)
155+ want, err := g.ListRefs(ctx, h.Name)
156+ require.NoError(t, err)
157+ got, err := a.ListRefs(ctx, h.Name)
158+ require.NoError(t, err)
159+ assert.Equal(t, want, got)
160+}
161+
162+// Errors that are not ErrUnsupported are returned, not hidden by a fallback.
163+func TestOtherOpenErrorsAreReturned(t *testing.T) {
164+ ctx := context.Background()
165+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
166+ f := commitOf(t, data, "broken")
167+ // A corrupt packed-refs is neither unsupported nor missing.
168+ require.NoError(t, os.WriteFile(filepath.Join(f.Bare, "packed-refs"), []byte("not a packed-refs file\n"), 0o644))
169+ _, err := a.ListRefs(ctx, f.Name)
170+ require.Error(t, err)
171+ assert.NotErrorIs(t, err, domain.ErrNotFound)
172+
173+ _, err = a.ListRefs(ctx, readertest.RepoName(t, "ghost"))
174+ assert.ErrorIs(t, err, domain.ErrNotFound)
175+}
176+
177+func TestCancelledContext(t *testing.T) {
178+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
179+ f := commitOf(t, data, "ctx")
180+ ctx, cancel := context.WithCancel(context.Background())
181+ cancel()
182+ _, err := a.ListRefs(ctx, f.Name)
183+ assert.ErrorIs(t, err, context.Canceled)
184+ _, err = a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
185+ assert.ErrorIs(t, err, context.Canceled)
186+ _, _, err = a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "a.txt"))
187+ assert.ErrorIs(t, err, context.Canceled)
188+}
189+
190+// A commit in an encoding other than UTF-8 is converted by git, not by the
191+// library: the adapter hands it to the CLI reader even when ermite-git is
192+// routed, and the answer is the one of git.
193+func TestEncodedCommitIsServedByGit(t *testing.T) {
194+ ctx := context.Background()
195+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
196+ f := readertest.NewFixture(t, data, "enc")
197+ f.Write("a.txt", "a\n")
198+ f.Git("add", "-A")
199+ f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "caf\xe9")
200+ want, err := g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
201+ require.NoError(t, err)
202+ got, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 5})
203+ require.NoError(t, err)
204+ assert.Equal(t, want, got)
205+ assert.Equal(t, "café", got.Commits[0].Message)
206+}
A internal/adapters/ermitegit/compare_test.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/adapters/ermitegit"
9+ "bnema.dev/ermite/internal/readertest"
10+)
11+
12+// TestCompareWithCLI asks the CLI reader and the adapter, with every method on
13+// ermite-git, the same questions about each fixture and requires the same
14+// answers, errors included.
15+func TestCompareWithCLI(t *testing.T) {
16+ for _, c := range readertest.Cases() {
17+ t.Run(c.Name, func(t *testing.T) {
18+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
19+ f := c.Build(t, data)
20+ readertest.Compare(t, g, a, f.Name, f.Bare, readertest.SmallBudget)
21+ })
22+ }
23+}
24+
25+// TestCompareDefaultRoutes is the comparison through the routes of production.
26+func TestCompareDefaultRoutes(t *testing.T) {
27+ for _, c := range readertest.Cases() {
28+ t.Run(c.Name, func(t *testing.T) {
29+ a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil)
30+ f := c.Build(t, data)
31+ readertest.Compare(t, g, a, f.Name, f.Bare, readertest.SmallBudget)
32+ })
33+ }
34+}
35+
36+// TestCompareWithCLIOnBenchRepo is the same comparison on a real repository
37+// (ERMITE_GIT_BENCH_REPO, for instance lazygit): clone, maintenance as ermite
38+// runs it, then the questions on refs, trees with last commits, blobs and
39+// pages of history.
40+func TestCompareWithCLIOnBenchRepo(t *testing.T) {
41+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
42+ repo := cloneBench(t, g, data)
43+ readertest.Compare(t, g, a, repo.name, repo.dir, readertest.LargeBudget)
44+}
A internal/adapters/ermitegit/conformance_test.go +30 -0
@@ -0,0 +1,30 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/adapters/ermitegit"
9+ "bnema.dev/ermite/internal/ports"
10+ "bnema.dev/ermite/internal/readertest"
11+)
12+
13+// TestConformance runs the shared RepoReader table against the adapter with
14+// every method sent to ermite-git, whatever DefaultRoutes says: a method may
15+// only be routed to ermite-git if it passes here.
16+func TestConformance(t *testing.T) {
17+ readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) {
18+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
19+ return a, data
20+ })
21+}
22+
23+// TestConformanceDefaultRoutes runs the same table through the routes of
24+// production, where some methods are served by the CLI reader.
25+func TestConformanceDefaultRoutes(t *testing.T) {
26+ readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) {
27+ a, _, data := newAdapter(t, ermitegit.DefaultRoutes(), nil)
28+ return a, data
29+ })
30+}
A internal/adapters/ermitegit/convert.go +517 -0
@@ -0,0 +1,517 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "time"
12+
13+ git "bnema.dev/ermite-git"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+const (
20+ headsPrefix = "refs/heads/"
21+ tagsPrefix = "refs/tags/"
22+)
23+
24+func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObjectID(id.String()) }
25+
26+// listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
27+// follows to its commit when it leads to one; HEAD takes the ids of its
28+// branch or tag, and only the name of an unborn branch.
29+//
30+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
31+func listRefs(r *git.Repo) ([]domain.Ref, error) {
32+ var refs []domain.Ref
33+ byName := make(map[string]domain.Ref)
34+ for _, prefix := range [...]string{headsPrefix, tagsPrefix} {
35+ kind := domain.RefKindBranch
36+ if prefix == tagsPrefix {
37+ kind = domain.RefKindTag
38+ }
39+ for ref, err := range r.Refs(prefix) {
40+ if err != nil {
41+ return nil, fmt.Errorf("list refs: %w", err)
42+ }
43+ out := domain.Ref{Name: ref.Name, ShortName: ref.Name[len(prefix):], Kind: kind}
44+ var err2 error
45+ if out.ID, err2 = domainID(ref.ID); err2 != nil {
46+ return nil, err2
47+ }
48+ commit, err := r.PeelRef(ref)
49+ switch {
50+ case err == nil:
51+ if out.CommitID, err2 = domainID(commit); err2 != nil {
52+ return nil, err2
53+ }
54+ case errors.Is(err, git.ErrWrongType):
55+ // A tag of a tree or a blob has no commit.
56+ default:
57+ return nil, fmt.Errorf("follow %s: %w", ref.Name, err)
58+ }
59+ refs = append(refs, out)
60+ byName[out.Name] = out
61+ }
62+ }
63+ head := domain.Ref{Name: "HEAD", Kind: domain.RefKindHead}
64+ target, _, err := r.Head()
65+ if err != nil {
66+ return nil, fmt.Errorf("read HEAD: %w", err)
67+ }
68+ if b, ok := byName[target]; ok && target != "" {
69+ head.ShortName, head.ID, head.CommitID = b.ShortName, b.ID, b.CommitID
70+ } else if short, ok := cutPrefix(target, headsPrefix); ok {
71+ head.ShortName = short // unborn branch
72+ }
73+ return append([]domain.Ref{head}, refs...), nil
74+}
75+
76+func cutPrefix(s, prefix string) (string, bool) {
77+ if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
78+ return s[len(prefix):], true
79+ }
80+ return s, false
81+}
82+
83+// resolved is a revision resolved to a commit.
84+type resolved struct {
85+ commit git.ObjectID
86+ ref string
87+}
88+
89+func (r resolved) domain() (domain.ResolvedRevision, error) {
90+ id, err := domainID(r.commit)
91+ return domain.ResolvedRevision{CommitID: id, Ref: r.ref}, err
92+}
93+
94+// peelRef reads a ref and peels it to a commit. ok is false when the ref does
95+// not exist or does not lead to a commit.
96+func peelRef(r *git.Repo, name string) (_ git.ObjectID, ok bool, _ error) {
97+ id, err := r.ResolveRef(name)
98+ if errors.Is(err, git.ErrNotFound) {
99+ return git.ObjectID{}, false, nil
100+ }
101+ if err != nil {
102+ return git.ObjectID{}, false, err
103+ }
104+ return peelID(r, id)
105+}
106+
107+func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
108+ commit, err := r.PeelToCommit(id)
109+ if errors.Is(err, git.ErrNotFound) || errors.Is(err, git.ErrWrongType) {
110+ return git.ObjectID{}, false, nil
111+ }
112+ if err != nil {
113+ return git.ObjectID{}, false, err
114+ }
115+ return commit, true, nil
116+}
117+
118+// resolveRevision tries the candidates of gitcli in the same order, which
119+// the port documents: HEAD is HEAD; a full object id is an object before it
120+// is a ref; then the branch, then the tag; then a full ref name and an
121+// abbreviated id as written. It does not use Repo.Resolve, whose order is
122+// git's own (tags before branches, no ref for a missing full id).
123+//
124+//hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract
125+func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
126+ s := rev.String()
127+ if rev.IsHead() || s == "HEAD" {
128+ if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
129+ return resolved{commit: c}, err
130+ }
131+ return resolved{}, notFound(rev)
132+ }
133+ if rev.IsObjectID() {
134+ id, err := git.ParseID(s)
135+ if err != nil {
136+ return resolved{}, err
137+ }
138+ if c, ok, err := peelID(r, id); err != nil || ok {
139+ return resolved{commit: c}, err
140+ }
141+ }
142+ for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {
143+ if c, ok, err := peelRef(r, name); err != nil || ok {
144+ return resolved{commit: c, ref: name}, err
145+ }
146+ }
147+ switch {
148+ case len(s) > 5 && s[:5] == "refs/":
149+ if c, ok, err := peelRef(r, s); err != nil || ok {
150+ return resolved{commit: c, ref: s}, err
151+ }
152+ case rev.IsAbbrevID():
153+ id, err := r.ResolveCommitPrefix(s)
154+ if err == nil {
155+ // The prefix may name an annotated tag: git peels it.
156+ id, err = r.PeelToCommit(id)
157+ }
158+ switch {
159+ case err == nil:
160+ return resolved{commit: id}, nil
161+ case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
162+ default:
163+ return resolved{}, err
164+ }
165+ }
166+ return resolved{}, notFound(rev)
167+}
168+
169+func notFound(rev domain.Revision) error {
170+ return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
171+}
172+
173+// canonMode is the mode git reports for a tree entry: legacy permission bits
174+// are normalized (tree-walk.c canon_mode).
175+func canonMode(m git.Mode) uint32 {
176+ switch {
177+ case m.IsRegular():
178+ if m&0o100 != 0 {
179+ return 0o100755
180+ }
181+ return 0o100644
182+ case m.IsSymlink():
183+ return 0o120000
184+ case m.IsTree():
185+ return 0o040000
186+ }
187+ return 0o160000
188+}
189+
190+// commitTree resolves rev and reads the root tree of the commit.
191+func commitTree(r *git.Repo, rev domain.Revision) (domain.ObjectID, git.ObjectID, error) {
192+ res, err := resolveRevision(r, rev)
193+ if err != nil {
194+ return domain.ObjectID{}, git.ObjectID{}, err
195+ }
196+ var c git.Commit
197+ if err := r.ReadCommit(res.commit, &c); err != nil {
198+ return domain.ObjectID{}, git.ObjectID{}, err
199+ }
200+ id, err := domainID(res.commit)
201+ return id, c.Tree, err
202+}
203+
204+// lookup finds the entry at a path of a commit tree. A path that does not
205+// exist, and a submodule (whose commit is not in the repository), are
206+// domain.ErrNotFound like in gitcli.
207+func lookup(r *git.Repo, tree git.ObjectID, path domain.TreePath) (git.Entry, error) {
208+ e, err := r.LookupPath(tree, path.String())
209+ if errors.Is(err, git.ErrNotFound) {
210+ return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
211+ }
212+ if err != nil {
213+ return git.Entry{}, err
214+ }
215+ if e.Mode.IsGitlink() {
216+ return git.Entry{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
217+ }
218+ return e, nil
219+}
220+
221+// getTree lists a directory.
222+//
223+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
224+func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath, includeLast bool) (domain.Tree, error) {
225+ commit, root, err := commitTree(r, rev)
226+ if err != nil {
227+ return domain.Tree{}, err
228+ }
229+ dir, err := lookup(r, root, path)
230+ if err != nil {
231+ return domain.Tree{}, err
232+ }
233+ if !dir.Mode.IsTree() {
234+ return domain.Tree{}, domain.ErrNotDirectory
235+ }
236+ treeID, err := domainID(dir.ID)
237+ if err != nil {
238+ return domain.Tree{}, err
239+ }
240+ tree := domain.Tree{CommitID: commit, ID: treeID}
241+ // The ids, the names and the paths of all the entries share one backing
242+ // string, cut into substrings.
243+ type span struct {
244+ name, path, id [2]int
245+ mode uint32
246+ kind domain.EntryKind
247+ }
248+ var (
249+ spans []span
250+ buf []byte
251+ bad error
252+ )
253+ prefix := path.String()
254+ err = r.ReadTree(dir.ID, func(e git.Entry) bool {
255+ mode := canonMode(e.Mode)
256+ kind := domain.EntryKindOf(mode)
257+ if kind == "" {
258+ bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name)
259+ return false
260+ }
261+ var sp span
262+ sp.mode, sp.kind = mode, kind
263+ sp.id[0] = len(buf)
264+ buf = e.ID.AppendHex(buf)
265+ sp.id[1] = len(buf)
266+ sp.name[0] = len(buf)
267+ buf = append(buf, e.Name...)
268+ sp.name[1] = len(buf)
269+ if prefix == "" {
270+ sp.path = sp.name
271+ } else {
272+ sp.path[0] = len(buf)
273+ buf = append(buf, prefix...)
274+ buf = append(buf, '/')
275+ buf = append(buf, e.Name...)
276+ sp.path[1] = len(buf)
277+ }
278+ spans = append(spans, sp)
279+ return true
280+ })
281+ if err == nil {
282+ err = bad
283+ }
284+ if err != nil {
285+ return domain.Tree{}, fmt.Errorf("list tree %q: %w", path, err)
286+ }
287+ text := string(buf)
288+ tree.Entries = make([]domain.TreeEntry, len(spans))
289+ for i, sp := range spans {
290+ id, err := domain.ParseObjectID(text[sp.id[0]:sp.id[1]])
291+ if err != nil {
292+ return domain.Tree{}, err
293+ }
294+ tree.Entries[i] = domain.TreeEntry{
295+ Name: text[sp.name[0]:sp.name[1]], Path: text[sp.path[0]:sp.path[1]],
296+ Kind: sp.kind, Mode: sp.mode, ID: id,
297+ }
298+ }
299+ domain.SortTreeEntries(tree.Entries)
300+ tree.BoundTree()
301+ // The size of a file costs a header read: only for the entries that stay.
302+ for i, e := range tree.Entries {
303+ if e.Kind == domain.EntryDirectory || e.Kind == domain.EntrySubmodule {
304+ continue
305+ }
306+ id, err := git.ParseID(e.ID.String())
307+ if err != nil {
308+ return domain.Tree{}, err
309+ }
310+ _, size, err := r.ObjectHeader(id)
311+ if err != nil {
312+ return domain.Tree{}, fmt.Errorf("size of %q: %w", e.Path, err)
313+ }
314+ tree.Entries[i].Size = size
315+ }
316+ if includeLast {
317+ if err := fillLastCommits(ctx, r, commit, path, &tree); err != nil {
318+ return domain.Tree{}, fmt.Errorf("last commits of %q: %w", path, err)
319+ }
320+ }
321+ return tree, nil
322+}
323+
324+// fillLastCommits sets the last commit of the first MaxLastCommitEntries
325+// entries with one walk.
326+//
327+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
328+func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, dir domain.TreePath, tree *domain.Tree) error {
329+ n := len(tree.Entries)
330+ if n > domain.MaxLastCommitEntries {
331+ n = domain.MaxLastCommitEntries
332+ tree.LastCommitsOmitted = true
333+ }
334+ if n == 0 {
335+ return nil
336+ }
337+ start, err := git.ParseID(commit.String())
338+ if err != nil {
339+ return err
340+ }
341+ names := make([]string, n)
342+ for i := range n {
343+ names[i] = tree.Entries[i].Name
344+ }
345+ ids, err := r.LastCommits(ctx, start, dir.String(), names)
346+ if err != nil {
347+ return err
348+ }
349+ var c git.Commit
350+ seen := make(map[git.ObjectID]domain.Commit, 8)
351+ for i, id := range ids {
352+ if id.IsZero() {
353+ return fmt.Errorf("no commit touches %q: %w", tree.Entries[i].Path, domain.ErrNotFound)
354+ }
355+ dc, ok := seen[id]
356+ if !ok {
357+ if err := r.ReadCommit(id, &c); err != nil {
358+ return err
359+ }
360+ if dc, err = convertCommit(&c); err != nil {
361+ return err
362+ }
363+ seen[id] = dc
364+ }
365+ cp := dc
366+ tree.Entries[i].LastCommit = &cp
367+ }
368+ return nil
369+}
370+
371+// convertCommit builds a domain commit. A commit in another encoding than
372+// UTF-8 is converted by git and not by the library: errFallback.
373+//
374+// The ids, the names, the emails and the message share one backing string, cut
375+// into substrings: one allocation for the text of a commit instead of one per
376+// field.
377+//
378+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
379+func convertCommit(c *git.Commit) (domain.Commit, error) {
380+ if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
381+ return domain.Commit{}, errFallback
382+ }
383+ buf := make([]byte, 0, 40*(1+len(c.Parents))+len(c.Author.Name)+len(c.Author.Email)+
384+ len(c.Committer.Name)+len(c.Committer.Email)+len(c.Message))
385+ buf = c.ID.AppendHex(buf)
386+ for _, p := range c.Parents {
387+ buf = p.AppendHex(buf)
388+ }
389+ end := [5]int{}
390+ for i, f := range [...][]byte{c.Author.Name, c.Author.Email, c.Committer.Name, c.Committer.Email, c.Message} {
391+ buf = append(buf, f...)
392+ end[i] = len(buf)
393+ }
394+ text := string(buf)
395+
396+ id, err := domain.ParseObjectID(text[:40])
397+ if err != nil {
398+ return domain.Commit{}, err
399+ }
400+ out := domain.Commit{ID: id}
401+ if len(c.Parents) > 0 {
402+ out.Parents = make([]domain.ObjectID, len(c.Parents))
403+ for i := range c.Parents {
404+ if out.Parents[i], err = domain.ParseObjectID(text[40*(i+1) : 40*(i+2)]); err != nil {
405+ return domain.Commit{}, err
406+ }
407+ }
408+ }
409+ at := 40 * (1 + len(c.Parents))
410+ out.Author = domain.Signature{
411+ Name: domain.ValidGitText(text[at:end[0]]), Email: domain.ValidGitText(text[end[0]:end[1]]),
412+ Time: time.Unix(c.Author.Time, 0).UTC(),
413+ }
414+ out.Committer = domain.Signature{
415+ Name: domain.ValidGitText(text[end[1]:end[2]]), Email: domain.ValidGitText(text[end[2]:end[3]]),
416+ Time: time.Unix(c.Committer.Time, 0).UTC(),
417+ }
418+ out.Message, out.Summary = domain.SplitCommitMessage(text[end[3]:])
419+ return out, nil
420+}
421+
422+func isUTF8Label(b []byte) bool {
423+ return bytes.EqualFold(b, []byte("utf-8")) || bytes.EqualFold(b, []byte("utf8"))
424+}
425+
426+// listCommits returns a page of history, one commit more than the page to
427+// know whether the history goes on.
428+func listCommits(ctx context.Context, r *git.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
429+ res, err := resolveRevision(r, q.Revision)
430+ if err != nil {
431+ return ports.CommitPage{}, err
432+ }
433+ head, err := res.domain()
434+ if err != nil {
435+ return ports.CommitPage{}, err
436+ }
437+ page := ports.CommitPage{Head: head.CommitID, Commits: make([]domain.Commit, 0, min(q.Limit, 64))}
438+ opts := git.WalkOptions{Skip: q.Skip, Limit: q.Limit + 1, Path: q.Path.String()}
439+ for c, err := range r.Walk(ctx, res.commit, opts) {
440+ if err != nil {
441+ return ports.CommitPage{}, err
442+ }
443+ if len(page.Commits) == q.Limit {
444+ page.HasMore = true
445+ break
446+ }
447+ dc, err := convertCommit(c)
448+ if err != nil {
449+ return ports.CommitPage{}, err
450+ }
451+ page.Commits = append(page.Commits, dc)
452+ }
453+ if len(page.Commits) == 0 {
454+ page.Commits = nil // like gitcli: an empty page has no slice
455+ }
456+ return page, nil
457+}
458+
459+// openBlob describes the file at path and opens its content. The first
460+// BinarySniffLen bytes are read to decide Binary and MediaType, then given
461+// back to the reader.
462+//
463+//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
464+func openBlob(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
465+ if path.IsRoot() {
466+ return domain.BlobInfo{}, nil, domain.ErrNotFile
467+ }
468+ _, root, err := commitTree(r, rev)
469+ if err != nil {
470+ return domain.BlobInfo{}, nil, err
471+ }
472+ e, err := lookup(r, root, path)
473+ if err != nil {
474+ return domain.BlobInfo{}, nil, err
475+ }
476+ if e.Mode.IsTree() {
477+ return domain.BlobInfo{}, nil, domain.ErrNotFile
478+ }
479+ id, err := domainID(e.ID)
480+ if err != nil {
481+ return domain.BlobInfo{}, nil, err
482+ }
483+ size, rc, err := r.OpenBlob(e.ID)
484+ if err != nil {
485+ return domain.BlobInfo{}, nil, err
486+ }
487+ info := domain.BlobInfo{ID: id, Path: path.String(), Size: size, Mode: canonMode(e.Mode)}
488+ head := make([]byte, min(size, int64(domain.BinarySniffLen)))
489+ if _, err := io.ReadFull(rc, head); err != nil {
490+ _ = rc.Close()
491+ return domain.BlobInfo{}, nil, fmt.Errorf("read blob: %w", err)
492+ }
493+ info.SniffContent(head)
494+ return info, &blobReader{ctx: ctx, head: head, rc: rc}, nil
495+}
496+
497+// blobReader replays the bytes read to sniff the content, then the rest of
498+// the blob. It stops when the context ends, as a gitcli stream does.
499+type blobReader struct {
500+ ctx context.Context
501+ head []byte
502+ rc io.ReadCloser
503+}
504+
505+func (b *blobReader) Read(p []byte) (int, error) {
506+ if err := b.ctx.Err(); err != nil {
507+ return 0, err
508+ }
509+ if len(b.head) > 0 {
510+ n := copy(p, b.head)
511+ b.head = b.head[n:]
512+ return n, nil
513+ }
514+ return b.rc.Read(p)
515+}
516+
517+func (b *blobReader) Close() error { return b.rc.Close() }
A internal/adapters/ermitegit/ermitegit.go +338 -0
@@ -0,0 +1,338 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package ermitegit implements ports.RepoReader over the ermite-git library.
4+//
5+// The adapter wraps the gitcli reader. Each method has a route, a constant
6+// below: the methods that match gitcli in the shared conformance table and
7+// beat it in the benchmarks are served by ermite-git, the others (and
8+// GetCommit) by the wrapped reader. A repository that ermite-git cannot read
9+// (ErrUnsupported: SHA-256, reftable, alternates, replace refs...) is served
10+// by the wrapped reader for every method.
11+//
12+// Any other error of the library is returned, never hidden by a fallback.
13+// The exceptions are decisions about the data, not failures: a commit whose
14+// message is not in UTF-8 (git converts it, the library does not) and an
15+// object larger than the library's in-memory bound are read by the wrapped
16+// reader.
17+//
18+// Freshness: a read that begins after a push has returned sees the push,
19+// whichever path made it, because the library reads refs from disk on each
20+// call (loose refs always, packed-refs after a stat) and rescans the packs
21+// when an object is missing (TestFreshness proves it with no Refresh at all).
22+// The adapter still calls Refresh, at most once per Config.RefreshEvery for
23+// each repository, to release replaced packs, pick up a new commit-graph and
24+// notice a repository that stopped being supported; a stat-based Refresh is
25+// 20 µs and 34 allocations, too much to pay on every read of a method that
26+// itself costs 20 µs. The cache of open repositories is bounded (LRU);
27+// gitcli's Trash tells the adapter to drop a deleted repository (see Forget).
28+package ermitegit
29+
30+import (
31+ "context"
32+ "errors"
33+ "fmt"
34+ "io"
35+ "sync"
36+ "time"
37+
38+ git "bnema.dev/ermite-git"
39+ "github.com/bnema/zerowrap"
40+
41+ "bnema.dev/ermite/internal/domain"
42+ "bnema.dev/ermite/internal/ports"
43+)
44+
45+var _ ports.RepoReader = (*Adapter)(nil)
46+
47+// ErrClosed is returned by reads after Close.
48+var ErrClosed = errors.New("ermitegit: reader is closed")
49+
50+// Backend says which reader serves a method.
51+type Backend uint8
52+
53+// Backends.
54+const (
55+ // CLI is the git binary, through the gitcli reader.
56+ CLI Backend = iota
57+ // ErmiteGit is the ermite-git library.
58+ ErmiteGit
59+)
60+
61+// String returns "cli" or "ermite-git".
62+func (b Backend) String() string {
63+ if b == ErmiteGit {
64+ return "ermite-git"
65+ }
66+ return "cli"
67+}
68+
69+// Routes says which reader serves each method. GetCommit is not here: it is
70+// always served by the wrapped reader.
71+type Routes struct {
72+ ListRefs Backend
73+ // ResolveRevision is a revision that is a name or a full id.
74+ ResolveRevision Backend
75+ // ResolveRevisionAbbrev is a revision that is an abbreviated id.
76+ ResolveRevisionAbbrev Backend
77+ ResolveRefPath Backend
78+ // GetTree is a directory listing without last commits.
79+ GetTree Backend
80+ // GetTreeLastCommits is a listing with the last commit of each entry.
81+ GetTreeLastCommits Backend
82+ OpenBlob Backend
83+ // ListCommits is a page of history of a revision.
84+ ListCommits Backend
85+ // ListCommitsPath is a page of the history of a file or a directory.
86+ ListCommitsPath Backend
87+}
88+
89+// AllRoutes returns routes that send every method to b. The tests and the
90+// benchmarks use it to exercise a backend whatever the production routes are.
91+func AllRoutes(b Backend) Routes {
92+ return Routes{b, b, b, b, b, b, b, b, b}
93+}
94+
95+// Config configures an Adapter.
96+type Config struct {
97+ // Fallback serves the methods that are not routed to ermite-git and the
98+ // repositories ermite-git cannot read. Required.
99+ Fallback ports.RepoReader
100+ // RepoDir returns the directory of a repository. It does not check that it
101+ // exists. Required.
102+ RepoDir func(domain.RepoName) string
103+ // RefreshEvery is the longest a repository goes without a Refresh of the
104+ // library while it is read. Zero means DefaultRefreshEvery; a negative
105+ // value refreshes before every read. It does not affect what a read
106+ // sees: see the package comment.
107+ RefreshEvery time.Duration
108+ // MaxRepos bounds the repositories kept open; zero means DefaultMaxRepos.
109+ MaxRepos int
110+ // Routes overrides DefaultRoutes when not nil.
111+ Routes *Routes
112+}
113+
114+// Adapter is a ports.RepoReader that routes each method to ermite-git or to
115+// the wrapped reader.
116+type Adapter struct {
117+ cli ports.RepoReader
118+ dirOf func(domain.RepoName) string
119+ routes Routes
120+ cache *cache
121+
122+ logged sync.Map // dir -> struct{}: the unsupported repositories already logged
123+}
124+
125+// New builds an adapter.
126+func New(cfg Config) *Adapter {
127+ routes := DefaultRoutes()
128+ if cfg.Routes != nil {
129+ routes = *cfg.Routes
130+ }
131+ refreshEvery := cfg.RefreshEvery
132+ if refreshEvery == 0 {
133+ refreshEvery = DefaultRefreshEvery
134+ }
135+ a := &Adapter{cli: cfg.Fallback, dirOf: cfg.RepoDir, routes: routes}
136+ a.cache = newCache(cfg.MaxRepos, refreshEvery, a.logUnsupported)
137+ return a
138+}
139+
140+// Close closes the open repositories. Reads after Close fail with ErrClosed;
141+// blob streams already returned stay valid until they are closed. The wrapped
142+// reader is not closed.
143+func (a *Adapter) Close() { a.cache.close() }
144+
145+// Forget drops the open repository of dir, which was moved away or deleted.
146+func (a *Adapter) Forget(dir string) {
147+ a.cache.forget(dir)
148+ a.logged.Delete(dir)
149+}
150+
151+// OpenRepos returns the number of repositories in the cache, for tests.
152+func (a *Adapter) OpenRepos() int { return a.cache.size() }
153+
154+// logUnsupported logs, once per directory, that a repository is served by git.
155+func (a *Adapter) logUnsupported(ctx context.Context, dir string, name domain.RepoName, cause error) {
156+ if _, seen := a.logged.LoadOrStore(dir, struct{}{}); seen {
157+ return
158+ }
159+ log := zerowrap.FromCtx(ctx)
160+ log.Info().Str("repo", name.String()).Str("reason", cause.Error()).Msg("repository is not readable by ermite-git, served by git")
161+}
162+
163+// errFallback makes serve hand the call to the wrapped reader.
164+var errFallback = errors.New("ermitegit: served by git")
165+
166+// serve runs fn on the open repository, or cli when the repository is
167+// unsupported, when fn asks for it, or when the library refuses on a bound.
168+func serve[T any](ctx context.Context, a *Adapter, repo domain.RepoName, fn func(*git.Repo) (T, error), cli func() (T, error)) (T, error) {
169+ var zero T
170+ if err := ctx.Err(); err != nil {
171+ return zero, err
172+ }
173+ h, err := a.cache.get(ctx, repo, a.dirOf(repo))
174+ if err != nil {
175+ return zero, err
176+ }
177+ defer h.release()
178+ if h.repo == nil {
179+ return cli()
180+ }
181+ v, err := fn(h.repo)
182+ switch {
183+ case err == nil:
184+ return v, nil
185+ case errors.Is(err, errFallback), errors.Is(err, git.ErrTooLarge):
186+ return cli()
187+ case errors.Is(err, git.ErrUnsupported):
188+ h.fallBack(ctx, repo, err)
189+ return cli()
190+ }
191+ return zero, mapErr(ctx, err)
192+}
193+
194+// mapErr turns the errors of the library into the ones of the port. A
195+// cancelled context stays itself.
196+func mapErr(ctx context.Context, err error) error {
197+ switch {
198+ case ctx.Err() != nil && errors.Is(err, ctx.Err()):
199+ return err
200+ case errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrNotFile), errors.Is(err, domain.ErrNotDirectory):
201+ return err // already one of the port's
202+ case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous):
203+ // gitcli reads an ambiguous abbreviation as a missing object.
204+ return fmt.Errorf("%w: %w", domain.ErrNotFound, err)
205+ case errors.Is(err, git.ErrInvalidPath):
206+ return fmt.Errorf("%w: %w", domain.ErrInvalidPath, err)
207+ case errors.Is(err, git.ErrInvalidID):
208+ return fmt.Errorf("%w: %w", domain.ErrInvalidObjectID, err)
209+ case errors.Is(err, git.ErrClosed):
210+ return fmt.Errorf("%w: %w", ErrClosed, err)
211+ }
212+ return err
213+}
214+
215+// ListRefs lists HEAD, the branches and the tags.
216+func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
217+ cli := func() ([]domain.Ref, error) { return a.cli.ListRefs(ctx, repo) }
218+ if a.routes.ListRefs == CLI {
219+ return cli()
220+ }
221+ return serve(ctx, a, repo, func(r *git.Repo) ([]domain.Ref, error) { return listRefs(r) }, cli)
222+}
223+
224+// ResolveRevision turns a revision into a commit.
225+//
226+//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
227+func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
228+ cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
229+ route := a.routes.ResolveRevision
230+ if rev.IsAbbrevID() {
231+ route = a.routes.ResolveRevisionAbbrev
232+ }
233+ if route == CLI {
234+ return cli()
235+ }
236+ return serve(ctx, a, repo, func(r *git.Repo) (domain.ResolvedRevision, error) {
237+ res, err := resolveRevision(r, rev)
238+ if err != nil {
239+ return domain.ResolvedRevision{}, fmt.Errorf("resolve %q in %q: %w", rev, repo, err)
240+ }
241+ return res.domain()
242+ }, cli)
243+}
244+
245+// ResolveRefPath splits a path into a revision and the rest.
246+//
247+//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
248+func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
249+ cli := func() (domain.RefPath, error) { return a.cli.ResolveRefPath(ctx, repo, path) }
250+ if a.routes.ResolveRefPath == CLI {
251+ return cli()
252+ }
253+ return serve(ctx, a, repo, func(r *git.Repo) (domain.RefPath, error) {
254+ out, err := domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) {
255+ res, err := resolveRevision(r, rev)
256+ if err != nil {
257+ return domain.ResolvedRevision{}, mapErr(ctx, err)
258+ }
259+ return res.domain()
260+ })
261+ if err != nil {
262+ return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
263+ }
264+ return out, nil
265+ }, cli)
266+}
267+
268+// GetTree lists a directory.
269+func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
270+ cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) }
271+ route := a.routes.GetTree
272+ if includeLastCommit {
273+ route = a.routes.GetTreeLastCommits
274+ }
275+ if route == CLI {
276+ return cli()
277+ }
278+ return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) {
279+ tree, err := getTree(ctx, r, rev, path, includeLastCommit)
280+ if err != nil {
281+ return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, err)
282+ }
283+ return tree, nil
284+ }, cli)
285+}
286+
287+// OpenBlob describes a file and returns its content as a stream.
288+func (a *Adapter) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
289+ type result struct {
290+ info domain.BlobInfo
291+ rc io.ReadCloser
292+ }
293+ cli := func() (result, error) {
294+ info, rc, err := a.cli.OpenBlob(ctx, repo, rev, path)
295+ return result{info, rc}, err
296+ }
297+ if a.routes.OpenBlob == CLI {
298+ res, err := cli()
299+ return res.info, res.rc, err
300+ }
301+ res, err := serve(ctx, a, repo, func(r *git.Repo) (result, error) {
302+ info, rc, err := openBlob(ctx, r, rev, path)
303+ if err != nil {
304+ return result{}, fmt.Errorf("blob %q of %q: %w", path, repo, err)
305+ }
306+ return result{info, rc}, nil
307+ }, cli)
308+ return res.info, res.rc, err
309+}
310+
311+// ListCommits returns a page of history.
312+//
313+//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
314+func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
315+ if err := q.Validate(); err != nil {
316+ return ports.CommitPage{}, err
317+ }
318+ cli := func() (ports.CommitPage, error) { return a.cli.ListCommits(ctx, repo, q) }
319+ route := a.routes.ListCommits
320+ if !q.Path.IsRoot() {
321+ route = a.routes.ListCommitsPath
322+ }
323+ if route == CLI {
324+ return cli()
325+ }
326+ return serve(ctx, a, repo, func(r *git.Repo) (ports.CommitPage, error) {
327+ page, err := listCommits(ctx, r, q)
328+ if err != nil {
329+ return ports.CommitPage{}, fmt.Errorf("history of %q: %w", repo, err)
330+ }
331+ return page, nil
332+ }, cli)
333+}
334+
335+// GetCommit is served by the wrapped reader: the diff stays on git.
336+func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
337+ return a.cli.GetCommit(ctx, repo, rev, limits)
338+}
A internal/adapters/ermitegit/freshness_test.go +183 -0
@@ -0,0 +1,183 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "context"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/ermitegit"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/readertest"
21+)
22+
23+// push sends the work tree's HEAD to the bare repository of the fixture as a
24+// push does: git receive-pack writes a pack (or loose objects) and updates
25+// the ref, with no help from the reader.
26+func push(t *testing.T, f *readertest.Fixture, remote, branch string, loose bool) {
27+ t.Helper()
28+ args := []string{"push", "--quiet", remote, "HEAD:refs/heads/" + branch}
29+ if loose {
30+ args = append([]string{"-c", "receive.unpackLimit=1000"}, args...)
31+ }
32+ cmd := exec.Command("git", args...)
33+ cmd.Dir = f.Work
34+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + f.Work, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_DIR=" + f.Bare}
35+ out, err := cmd.CombinedOutput()
36+ require.NoError(t, err, string(out))
37+}
38+
39+// TestFreshness proves that a read that starts after a push sees the push
40+// with the adapter's own Refresh disabled (an interval of one hour: only the
41+// first read refreshes): new commits in a pack and as loose objects, new
42+// branches, a ref that moves, a repack that replaces the packs, a new
43+// commit-graph layer, a deleted branch, tags.
44+func TestFreshness(t *testing.T) {
45+ ctx := context.Background()
46+ a, g, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour })
47+
48+ // The work tree is an ordinary clone-like repository; "remote" is the
49+ // bare repository the adapter reads.
50+ f := readertest.NewFixture(t, data, "pushed")
51+ bare := f.Bare
52+ // A separate repository makes the commits and pushes them.
53+ work := readertest.NewFixture(t, t.TempDir(), "work")
54+ work.Write("a.txt", "1\n")
55+ work.Commit("first")
56+ push(t, work, bare, "main", false)
57+
58+ head := func(t *testing.T) string {
59+ t.Helper()
60+ res, err := a.ResolveRevision(ctx, f.Name, domain.Revision{})
61+ require.NoError(t, err)
62+ return res.CommitID.String()
63+ }
64+ rev := func(s string) domain.Revision {
65+ r, err := domain.ParseRevision(s)
66+ require.NoError(t, err)
67+ return r
68+ }
69+ cli := func(t *testing.T) string {
70+ t.Helper()
71+ res, err := g.ResolveRevision(ctx, f.Name, domain.Revision{})
72+ require.NoError(t, err)
73+ return res.CommitID.String()
74+ }
75+ require.Equal(t, work.Git("rev-parse", "HEAD"), head(t), "the first read")
76+
77+ for i, step := range []struct {
78+ name string
79+ loose bool
80+ after func()
81+ }{
82+ {"pack", false, nil},
83+ {"loose objects", true, nil},
84+ {"repack replaces the packs", false, func() { f.Git("repack", "-adf") }},
85+ {"commit-graph layer", false, func() { f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split") }},
86+ {"gc packs the refs", false, func() { f.Git("gc", "--quiet", "--prune=now") }},
87+ } {
88+ t.Run(step.name, func(t *testing.T) {
89+ work.Write("a.txt", strings.Repeat("x", i+2)+"\n").Write("dir/new.txt", step.name+"\n")
90+ c := work.Commit("push " + step.name)
91+ push(t, work, bare, "main", step.loose)
92+ // Not a single Refresh in between: the very next read sees it.
93+ assert.Equal(t, c, head(t))
94+ page, err := a.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 3})
95+ require.NoError(t, err)
96+ require.NotEmpty(t, page.Commits)
97+ assert.Equal(t, c, page.Commits[0].ID.String())
98+ tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
99+ require.NoError(t, err)
100+ require.NotNil(t, tree.Entries[0].LastCommit)
101+ assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String(), "dir/ was last touched by the push")
102+ _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "dir/new.txt"))
103+ require.NoError(t, err)
104+ body, err := io.ReadAll(rc)
105+ require.NoError(t, err)
106+ require.NoError(t, rc.Close())
107+ assert.Equal(t, step.name+"\n", string(body))
108+ assert.Equal(t, cli(t), head(t), "the same answer as git")
109+
110+ if step.after != nil {
111+ step.after()
112+ assert.Equal(t, c, head(t), "after maintenance")
113+ tree, err := a.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
114+ require.NoError(t, err)
115+ assert.Equal(t, c, tree.Entries[0].LastCommit.ID.String())
116+ }
117+ })
118+ }
119+
120+ t.Run("new and deleted refs", func(t *testing.T) {
121+ work.Git("branch", "-f", "feature/new", "HEAD")
122+ push(t, work, bare, "feature/new", false)
123+ res, err := a.ResolveRevision(ctx, f.Name, rev("feature/new"))
124+ require.NoError(t, err)
125+ assert.Equal(t, "refs/heads/feature/new", res.Ref)
126+
127+ work.Git("tag", "-a", "-m", "release", "v9")
128+ work.Git("push", "--quiet", bare, "refs/tags/v9")
129+ refs, err := a.ListRefs(ctx, f.Name)
130+ require.NoError(t, err)
131+ var names []string
132+ for _, r := range refs {
133+ names = append(names, r.Name)
134+ }
135+ assert.Contains(t, names, "refs/tags/v9")
136+ want, err := g.ListRefs(ctx, f.Name)
137+ require.NoError(t, err)
138+ assert.Equal(t, want, refs)
139+
140+ f.Git("update-ref", "-d", "refs/heads/feature/new")
141+ _, err = a.ResolveRevision(ctx, f.Name, rev("feature/new"))
142+ assert.ErrorIs(t, err, domain.ErrNotFound)
143+ f.Git("pack-refs", "--all")
144+ f.Git("update-ref", "-d", "refs/tags/v9")
145+ _, err = a.ResolveRevision(ctx, f.Name, rev("v9"))
146+ assert.ErrorIs(t, err, domain.ErrNotFound)
147+ })
148+
149+ t.Run("the repository is deleted and recreated", func(t *testing.T) {
150+ require.NoError(t, g.Trash(ctx, f.Name))
151+ _, err := a.ListRefs(ctx, f.Name)
152+ assert.ErrorIs(t, err, domain.ErrNotFound)
153+ assert.Equal(t, 0, a.OpenRepos(), "the trash hook dropped the repository")
154+
155+ again := readertest.NewFixture(t, data, "pushed")
156+ again.Write("b.txt", "new repo\n")
157+ c := again.Commit("a new repository under the old name")
158+ assert.Equal(t, c, head(t), "no trace of the old repository")
159+ })
160+}
161+
162+// TestReplacedDirectoryIsNotConfused: the cache is keyed by directory, so a
163+// directory replaced by another repository (a restore, a rename onto the same
164+// name) must not serve the old one: the inode tells.
165+func TestReplacedDirectoryIsNotConfused(t *testing.T) {
166+ ctx := context.Background()
167+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.RefreshEvery = time.Hour })
168+ one := readertest.NewFixture(t, data, "one")
169+ one.Write("a", "1\n")
170+ c1 := one.Commit("one")
171+ res, err := a.ResolveRevision(ctx, one.Name, domain.Revision{})
172+ require.NoError(t, err)
173+ require.Equal(t, c1, res.CommitID.String())
174+
175+ two := readertest.NewFixture(t, t.TempDir(), "two")
176+ two.Write("b", "2\n")
177+ c2 := two.Commit("two")
178+ require.NoError(t, os.RemoveAll(one.Bare))
179+ require.NoError(t, os.Rename(two.Bare, one.Bare))
180+ res, err = a.ResolveRevision(ctx, one.Name, domain.Revision{})
181+ require.NoError(t, err)
182+ assert.Equal(t, c2, res.CommitID.String())
183+}
A internal/adapters/ermitegit/helpers_test.go +63 -0
@@ -0,0 +1,63 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/ermitegit"
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// newAdapter prepares a data directory and an ermitegit adapter around a
21+// gitcli reader, with the given routes. Without git it skips locally and
22+// fails when CI is set. Both are closed when the test ends.
23+func newAdapter(t testing.TB, routes ermitegit.Routes, tune func(*ermitegit.Config)) (*ermitegit.Adapter, *gitcli.Git, string) {
24+ t.Helper()
25+ bin, err := exec.LookPath("git")
26+ if err != nil {
27+ unusable(t, "git not found")
28+ }
29+ data := t.TempDir()
30+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
31+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
32+ }
33+ g := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")})
34+ if _, err := g.CheckVersion(context.Background()); err != nil {
35+ unusable(t, "git is not usable by ermite: %v", err)
36+ }
37+ cfg := ermitegit.Config{
38+ Fallback: g,
39+ RepoDir: func(n domain.RepoName) string { return gitcli.RepoDir(data, n) },
40+ Routes: &routes,
41+ }
42+ if tune != nil {
43+ tune(&cfg)
44+ }
45+ a := ermitegit.New(cfg)
46+ g.OnTrash(a.Forget)
47+ t.Cleanup(func() {
48+ a.Close()
49+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
50+ defer cancel()
51+ require.NoError(t, g.Close(ctx))
52+ })
53+ return a, g, data
54+}
55+
56+// unusable skips the test, or fails it in CI so that CI cannot pass vacuously.
57+func unusable(t testing.TB, format string, args ...any) {
58+ t.Helper()
59+ if os.Getenv("CI") != "" {
60+ t.Fatalf(format, args...)
61+ }
62+ t.Skipf(format, args...)
63+}
A internal/adapters/ermitegit/routes.go +56 -0
@@ -0,0 +1,56 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+// DefaultRoutes returns the routes of production.
6+//
7+// A method is routed to ermite-git only when it passes the shared conformance
8+// table (TestConformance, TestCompareWithCLI) and its benchmark beats the CLI
9+// adapter in time AND in allocations, on the generated fixture and on lazygit
10+// (BenchmarkRepoReader, ERMITE_GIT_BENCH_REPO). A method that does not stays
11+// on the CLI, with the numbers that decided it. GetCommit is not routable: the
12+// diff stays on git.
13+//
14+// The numbers are the median of three runs of `go test -bench RepoReader
15+// -benchmem` on an AMD Ryzen 9 7900X3D, git 2.56, ermite-git ea9bcf1, as
16+// "time / allocations per call", CLI against ermite-git. The fixture has 220
17+// commits, 8 directories of 40 files and a 10 MiB blob; lazygit has 7,550
18+// commits. Only the allocations of the Go process are counted, which favours
19+// the CLI (its work happens in git processes).
20+//
21+// fixture lazygit
22+// ListRefs 900 µs/126 → 29 µs/89 900 µs/89 → 20 µs/43 win
23+// ResolveRevision branch 24 µs/19 → 5 µs/14 26 µs/19 → 5 µs/14 win
24+// ResolveRevision full id 10 µs/22 → 2 µs/6 9 µs/22 → 2 µs/6 win
25+// ResolveRevision abbrev 65 µs/31 → 12 µs/33 65 µs/31 → 13 µs/33 allocs +2
26+// ResolveRefPath 108 µs/59 → 20 µs/72 159 µs/79 → 28 µs/101 allocs +13
27+// GetTree dir 736 µs/141 → 26 µs/51 1.3 ms/1059 → 3.5 ms/2634 loses on lazygit
28+// GetTree root 717 µs/114 → 14 µs/36 812 µs/167 → 50 µs/78 win
29+// GetTree root+last 4.0 ms/1022 → 73 µs/139 20 ms/3084 → 6.2 ms/4470 allocs +45%
30+// GetTree dir+last 5.3 ms/1452 → 176 µs/247 196 ms/17143 → 11 ms/8058 win
31+// OpenBlob small 46 µs/25 → 17 µs/28 53 µs/25 → 29 µs/57 allocs +2.3x
32+// OpenBlob 10 MiB 21 ms/64 → 49 ms/28 3.4 ms/64 → 5.6 ms/140 loses (inflate speed)
33+// ListCommits page 1 1.0 ms/404 → 59 µs/149 1.3 ms/415 → 368 µs/265 win
34+// ListCommits skip 5000 1.0 ms/404 → 74 µs/149 2.0 ms/404 → 433 µs/153 win
35+// ListCommits -- file 1.6 ms/138 → 453 µs/355 12.7 ms/117 → 6.1 ms/6870 allocs +58x
36+// ListCommits -- dir 1.3 ms/403 → 202 µs/239 6.6 ms/403 → 2.0 ms/1988 allocs +5x
37+//
38+// Where a method has several shapes, every shape must win to be routed: the
39+// constants GetTree, GetTreeLastCommits and ListCommitsPath cover directories
40+// of any size and files of any history. The losses in allocations come from
41+// the inflate of tree and commit objects (compress/zlib allocates an adler32
42+// digest and the flate tables on every Reset): once the library avoids that,
43+// re-run the benchmark and flip the constants it wins.
44+func DefaultRoutes() Routes {
45+ return Routes{
46+ ListRefs: ErmiteGit,
47+ ResolveRevision: ErmiteGit,
48+ ResolveRevisionAbbrev: CLI,
49+ ResolveRefPath: CLI,
50+ GetTree: CLI,
51+ GetTreeLastCommits: CLI,
52+ OpenBlob: CLI,
53+ ListCommits: ErmiteGit,
54+ ListCommitsPath: CLI,
55+ }
56+}
A internal/adapters/gitcli/conformance_test.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "testing"
7+
8+ "bnema.dev/ermite/internal/ports"
9+ "bnema.dev/ermite/internal/readertest"
10+)
11+
12+// TestConformance runs the shared RepoReader table against the CLI adapter,
13+// the oracle of the others.
14+func TestConformance(t *testing.T) {
15+ readertest.Conformance(t, func(t *testing.T) (ports.RepoReader, string) {
16+ g, data := newReader(t, nil)
17+ return g, data
18+ })
19+}
M internal/adapters/gitcli/diff_test.go +75 -74
@@ -13,6 +13,7 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/readertest"
16 )17 )
17 18
18 // unified renders hunks the way git prints them after the "+++" line.19 // unified renders hunks the way git prints them after the "+++" line.
@@ -58,16 +59,16 @@ func gitSections(out string) []string {
58 }59 }
59 60
60 // showSections is git's own patch text of a commit against its first parent.61 // showSections is git's own patch text of a commit against its first parent.
61-func (f *fixtureRepo) showSections(id string) []string {62+func showSections(f *readertest.Fixture, id string) []string {
62- f.t.Helper()63+ f.T.Helper()
63- out := f.git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id)64+ out := f.Git("show", "--format=", "--no-color", "-M", "-m", "--first-parent", "-U3", id)
64 return gitSections(strings.ToValidUTF8(out, "\uFFFD"))65 return gitSections(strings.ToValidUTF8(out, "\uFFFD"))
65 }66 }
66 67
67 // showNumstat is git's own numstat of a commit against its first parent.68 // showNumstat is git's own numstat of a commit against its first parent.
68-func (f *fixtureRepo) showNumstat(id string) []string {69+func showNumstat(f *readertest.Fixture, id string) []string {
69- f.t.Helper()70+ f.T.Helper()
70- out := f.git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id)71+ out := f.Git("show", "--format=", "-M", "-m", "--first-parent", "--numstat", id)
71 if out == "" {72 if out == "" {
72 return nil73 return nil
73 }74 }
@@ -76,11 +77,11 @@ func (f *fixtureRepo) showNumstat(id string) []string {
76 77
77 // assertMatchesGitShow checks the structured diff of a commit against the78 // assertMatchesGitShow checks the structured diff of a commit against the
78 // text of `git show`: the patch of every file, its counts, and the totals.79 // text of `git show`: the patch of every file, its counts, and the totals.
79-func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.CommitDetail) {80+func assertMatchesGitShow(t *testing.T, f *readertest.Fixture, id string, d domain.CommitDetail) {
80 t.Helper()81 t.Helper()
81- sections := f.showSections(id)82+ sections := showSections(f, id)
82 var adds, dels int83 var adds, dels int
83- numstat := f.showNumstat(id)84+ numstat := showNumstat(f, id)
84 require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")85 require.Len(t, numstat, len(d.Diff.Files), "one numstat line per file")
85 si := 086 si := 0
86 for i, file := range d.Diff.Files {87 for i, file := range d.Diff.Files {
@@ -113,21 +114,21 @@ func assertMatchesGitShow(t *testing.T, f *fixtureRepo, id string, d domain.Comm
113 func TestGetCommitMatchesGitShow(t *testing.T) {114 func TestGetCommitMatchesGitShow(t *testing.T) {
114 ctx := context.Background()115 ctx := context.Background()
115 g, data := newReader(t, nil)116 g, data := newReader(t, nil)
116- h := newHistory(t, data)117+ h := readertest.NewHistory(t, data)
117 118
118 for _, tt := range []struct{ name, id string }{119 for _, tt := range []struct{ name, id string }{
119- {"root commit: adds, binary, empty file", h.c1},120+ {"root commit: adds, binary, empty file", h.C1},
120- {"rename with change, binary, mode change, add, delete", h.c2},121+ {"rename with change, binary, mode change, add, delete", h.C2},
121- {"type change", h.c3},122+ {"type change", h.C3},
122- {"merge, against the first parent", h.merge},123+ {"merge, against the first parent", h.Merge},
123- {"side branch", h.side},124+ {"side branch", h.Side},
124 } {125 } {
125 t.Run(tt.name, func(t *testing.T) {126 t.Run(tt.name, func(t *testing.T) {
126- d, err := g.GetCommit(ctx, h.name, domain.RevisionOf(mustID(t, tt.id)), domain.DefaultDiffLimits())127+ d, err := g.GetCommit(ctx, h.Name, domain.RevisionOf(readertest.MustID(t, tt.id)), domain.DefaultDiffLimits())
127 require.NoError(t, err)128 require.NoError(t, err)
128 assert.Equal(t, tt.id, d.Commit.ID.String())129 assert.Equal(t, tt.id, d.Commit.ID.String())
129 assert.False(t, d.Diff.Truncated)130 assert.False(t, d.Diff.Truncated)
130- assertMatchesGitShow(t, h.fixtureRepo, tt.id, d)131+ assertMatchesGitShow(t, h.Fixture, tt.id, d)
131 })132 })
132 }133 }
133 }134 }
@@ -135,7 +136,7 @@ func TestGetCommitMatchesGitShow(t *testing.T) {
135 func TestGetCommitFileMetadata(t *testing.T) {136 func TestGetCommitFileMetadata(t *testing.T) {
136 ctx := context.Background()137 ctx := context.Background()
137 g, data := newReader(t, nil)138 g, data := newReader(t, nil)
138- h := newHistory(t, data)139+ h := readertest.NewHistory(t, data)
139 140
140 byPath := func(d domain.Diff) map[string]domain.FileDiff {141 byPath := func(d domain.Diff) map[string]domain.FileDiff {
141 m := map[string]domain.FileDiff{}142 m := map[string]domain.FileDiff{}
@@ -144,11 +145,11 @@ func TestGetCommitFileMetadata(t *testing.T) {
144 }145 }
145 return m146 return m
146 }147 }
147- id := func(spec string) domain.ObjectID { return mustID(t, h.git("rev-parse", spec)) }148+ id := func(spec string) domain.ObjectID { return readertest.MustID(t, h.Git("rev-parse", spec)) }
148 149
149- d, err := g.GetCommit(ctx, h.name, rev(t, "v2"), domain.DefaultDiffLimits())150+ d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "v2"), domain.DefaultDiffLimits())
150 require.NoError(t, err)151 require.NoError(t, err)
151- assert.Equal(t, h.c2, d.Commit.ID.String())152+ assert.Equal(t, h.C2, d.Commit.ID.String())
152 files := byPath(d.Diff)153 files := byPath(d.Diff)
153 require.Len(t, files, 5)154 require.Len(t, files, 5)
154 155
@@ -159,8 +160,8 @@ func TestGetCommitFileMetadata(t *testing.T) {
159 assert.Equal(t, 0, ren.Deletions)160 assert.Equal(t, 0, ren.Deletions)
160 assert.Equal(t, uint32(0o100644), ren.OldMode)161 assert.Equal(t, uint32(0o100644), ren.OldMode)
161 assert.Equal(t, uint32(0o100644), ren.NewMode)162 assert.Equal(t, uint32(0o100644), ren.NewMode)
162- assert.Equal(t, id(h.c1+":a.txt"), ren.OldID)163+ assert.Equal(t, id(h.C1+":a.txt"), ren.OldID)
163- assert.Equal(t, id(h.c2+":b.txt"), ren.NewID)164+ assert.Equal(t, id(h.C2+":b.txt"), ren.NewID)
164 require.Len(t, ren.Hunks, 1)165 require.Len(t, ren.Hunks, 1)
165 assert.Equal(t, domain.Hunk{166 assert.Equal(t, domain.Hunk{
166 OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,167 OldStart: 1, OldLines: 3, NewStart: 1, NewLines: 4,
@@ -203,7 +204,7 @@ func TestGetCommitFileMetadata(t *testing.T) {
203 last := add.Hunks[0].Lines[1]204 last := add.Hunks[0].Lines[1]
204 assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)205 assert.Equal(t, domain.Line{Kind: domain.LineAddition, NewNumber: 2, Text: "nonl", NoNewlineAtEOF: true}, last)
205 206
206- d, err = g.GetCommit(ctx, h.name, rev(t, h.c3), domain.DefaultDiffLimits())207+ d, err = g.GetCommit(ctx, h.Name, readertest.Rev(t, h.C3), domain.DefaultDiffLimits())
207 require.NoError(t, err)208 require.NoError(t, err)
208 require.Len(t, d.Diff.Files, 1)209 require.Len(t, d.Diff.Files, 1)
209 tc := d.Diff.Files[0]210 tc := d.Diff.Files[0]
@@ -216,9 +217,9 @@ func TestGetCommitFileMetadata(t *testing.T) {
216 func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {217 func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
217 ctx := context.Background()218 ctx := context.Background()
218 g, data := newReader(t, nil)219 g, data := newReader(t, nil)
219- h := newHistory(t, data)220+ h := readertest.NewHistory(t, data)
220 221
221- d, err := g.GetCommit(ctx, h.name, rev(t, "main"), domain.DefaultDiffLimits())222+ d, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "main"), domain.DefaultDiffLimits())
222 require.NoError(t, err)223 require.NoError(t, err)
223 require.Len(t, d.Commit.Parents, 2)224 require.Len(t, d.Commit.Parents, 2)
224 require.Len(t, d.Diff.Files, 1)225 require.Len(t, d.Diff.Files, 1)
@@ -229,10 +230,10 @@ func TestGetCommitMergeDiffIsAgainstFirstParent(t *testing.T) {
229 func TestGetCommitEmptyCommit(t *testing.T) {230 func TestGetCommitEmptyCommit(t *testing.T) {
230 ctx := context.Background()231 ctx := context.Background()
231 g, data := newReader(t, nil)232 g, data := newReader(t, nil)
232- f := newFixtureRepo(t, data, "empty-commit")233+ f := readertest.NewFixture(t, data, "empty-commit")
233- id := f.commit("nothing")234+ id := f.Commit("nothing")
234 235
235- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())236+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
236 require.NoError(t, err)237 require.NoError(t, err)
237 assert.Equal(t, id, d.Commit.ID.String())238 assert.Equal(t, id, d.Commit.ID.String())
238 assert.Empty(t, d.Diff.Files)239 assert.Empty(t, d.Diff.Files)
@@ -243,9 +244,9 @@ func TestGetCommitEmptyCommit(t *testing.T) {
243 func TestGetCommitErrors(t *testing.T) {244 func TestGetCommitErrors(t *testing.T) {
244 ctx := context.Background()245 ctx := context.Background()
245 g, data := newReader(t, nil)246 g, data := newReader(t, nil)
246- h := newHistory(t, data)247+ h := readertest.NewHistory(t, data)
247 248
248- _, err := g.GetCommit(ctx, h.name, rev(t, "nope"), domain.DefaultDiffLimits())249+ _, err := g.GetCommit(ctx, h.Name, readertest.Rev(t, "nope"), domain.DefaultDiffLimits())
249 assert.ErrorIs(t, err, domain.ErrNotFound)250 assert.ErrorIs(t, err, domain.ErrNotFound)
250 _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())251 _, err = g.GetCommit(ctx, name(t, "nope"), domain.Revision{}, domain.DefaultDiffLimits())
251 assert.ErrorIs(t, err, domain.ErrNotFound)252 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -254,19 +255,19 @@ func TestGetCommitErrors(t *testing.T) {
254 func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {255 func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
255 ctx := context.Background()256 ctx := context.Background()
256 g, data := newReader(t, nil)257 g, data := newReader(t, nil)
257- f := newFixtureRepo(t, data, "hunks")258+ f := readertest.NewFixture(t, data, "hunks")
258 var src strings.Builder259 var src strings.Builder
259 src.WriteString("package x\n\nfunc Alpha() {\n")260 src.WriteString("package x\n\nfunc Alpha() {\n")
260 for i := range 40 {261 for i := range 40 {
261 fmt.Fprintf(&src, "\tline(%d)\n", i)262 fmt.Fprintf(&src, "\tline(%d)\n", i)
262 }263 }
263 src.WriteString("}\n")264 src.WriteString("}\n")
264- f.write("x.go", src.String()).write("latin", "caf\xe9\nok\n")265+ f.Write("x.go", src.String()).Write("latin", "caf\xe9\nok\n")
265- f.commit("v1")266+ f.Commit("v1")
266- f.write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).write("latin", "caf\xe9\nOK\n")267+ f.Write("x.go", strings.Replace(src.String(), "\tline(30)", "\tchanged(30)", 1)).Write("latin", "caf\xe9\nOK\n")
267- id := f.commit("v2")268+ id := f.Commit("v2")
268 269
269- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())270+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
270 require.NoError(t, err)271 require.NoError(t, err)
271 assertMatchesGitShow(t, f, id, d)272 assertMatchesGitShow(t, f, id, d)
272 var gofile, latin domain.FileDiff273 var gofile, latin domain.FileDiff
@@ -286,11 +287,11 @@ func TestGetCommitHunkHeaderAndNonUTF8(t *testing.T) {
286 }287 }
287 288
288 // manyLines commits files and returns the id.289 // manyLines commits files and returns the id.
289-func commitFiles(f *fixtureRepo, msg string, files map[string]int) string {290+func commitFiles(f *readertest.Fixture, msg string, files map[string]int) string {
290 for p, n := range files {291 for p, n := range files {
291- f.lines(p, strings.ReplaceAll(p, "/", "_"), n)292+ f.Lines(p, strings.ReplaceAll(p, "/", "_"), n)
292 }293 }
293- return f.commit(msg)294+ return f.Commit(msg)
294 }295 }
295 296
296 func TestDiffLimits(t *testing.T) {297 func TestDiffLimits(t *testing.T) {
@@ -308,9 +309,9 @@ func TestDiffLimits(t *testing.T) {
308 }309 }
309 310
310 t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) {311 t.Run("a file at the line limit is kept, one line more is truncated", func(t *testing.T) {
311- f := newFixtureRepo(t, data, "limit-file")312+ f := readertest.NewFixture(t, data, "limit-file")
312 commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3})313 commitFiles(f, "files", map[string]int{"exact": 2000, "over": 2001, "small": 3})
313- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)314+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
314 require.NoError(t, err)315 require.NoError(t, err)
315 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")316 exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small")
316 317
@@ -327,8 +328,8 @@ func TestDiffLimits(t *testing.T) {
327 })328 })
328 329
329 t.Run("context counts towards the file limit", func(t *testing.T) {330 t.Run("context counts towards the file limit", func(t *testing.T) {
330- f := newFixtureRepo(t, data, "limit-context")331+ f := readertest.NewFixture(t, data, "limit-context")
331- f.lines("big", "l", 3000).commit("v1")332+ f.Lines("big", "l", 3000).Commit("v1")
332 // Every 7th line changes: ~428 changed lines, but the hunks merge333 // Every 7th line changes: ~428 changed lines, but the hunks merge
333 // into one that spans the file with its context.334 // into one that spans the file with its context.
334 var b strings.Builder335 var b strings.Builder
@@ -339,10 +340,10 @@ func TestDiffLimits(t *testing.T) {
339 fmt.Fprintf(&b, "l %d\n", i)340 fmt.Fprintf(&b, "l %d\n", i)
340 }341 }
341 }342 }
342- f.write("big", b.String())343+ f.Write("big", b.String())
343- f.write("tiny", "x\n")344+ f.Write("tiny", "x\n")
344- id := f.commit("v2")345+ id := f.Commit("v2")
345- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)346+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
346 require.NoError(t, err)347 require.NoError(t, err)
347 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
348 assert.True(t, big.Truncated)349 assert.True(t, big.Truncated)
@@ -355,7 +356,7 @@ func TestDiffLimits(t *testing.T) {
355 })356 })
356 357
357 t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {358 t.Run("the total limit truncates the file that does not fit", func(t *testing.T) {
358- f := newFixtureRepo(t, data, "limit-total")359+ f := readertest.NewFixture(t, data, "limit-total")
359 files := map[string]int{}360 files := map[string]int{}
360 for i := range 11 {361 for i := range 11 {
361 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000362 files[fmt.Sprintf("f%02d", i)] = 1900 // 11 x 1900 = 20,900 > 20,000
@@ -364,7 +365,7 @@ func TestDiffLimits(t *testing.T) {
364 files["h-big"] = 1500 // does not fit365 files["h-big"] = 1500 // does not fit
365 files["i-small"] = 10 // fits366 files["i-small"] = 10 // fits
366 commitFiles(f, "total", files)367 commitFiles(f, "total", files)
367- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)368+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
368 require.NoError(t, err)369 require.NoError(t, err)
369 require.Len(t, d.Diff.Files, 14)370 require.Len(t, d.Diff.Files, 14)
370 total := 0371 total := 0
@@ -387,12 +388,12 @@ func TestDiffLimits(t *testing.T) {
387 })388 })
388 389
389 t.Run("files past the file limit are omitted but counted", func(t *testing.T) {390 t.Run("files past the file limit are omitted but counted", func(t *testing.T) {
390- f := newFixtureRepo(t, data, "limit-files")391+ f := readertest.NewFixture(t, data, "limit-files")
391 for i := range 305 {392 for i := range 305 {
392- f.write(fmt.Sprintf("many/f%03d", i), "x\n")393+ f.Write(fmt.Sprintf("many/f%03d", i), "x\n")
393 }394 }
394- f.commit("many")395+ f.Commit("many")
395- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)396+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
396 require.NoError(t, err)397 require.NoError(t, err)
397 assert.Len(t, d.Diff.Files, 300)398 assert.Len(t, d.Diff.Files, 300)
398 assert.True(t, d.Diff.Truncated)399 assert.True(t, d.Diff.Truncated)
@@ -406,28 +407,28 @@ func TestDiffLimits(t *testing.T) {
406 })407 })
407 408
408 t.Run("exactly 300 files is not truncated", func(t *testing.T) {409 t.Run("exactly 300 files is not truncated", func(t *testing.T) {
409- f := newFixtureRepo(t, data, "limit-300")410+ f := readertest.NewFixture(t, data, "limit-300")
410 for i := range 300 {411 for i := range 300 {
411- f.write(fmt.Sprintf("f%03d", i), "x\n")412+ f.Write(fmt.Sprintf("f%03d", i), "x\n")
412 }413 }
413- f.commit("300")414+ f.Commit("300")
414- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)415+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
415 require.NoError(t, err)416 require.NoError(t, err)
416 assert.Len(t, d.Diff.Files, 300)417 assert.Len(t, d.Diff.Files, 300)
417 assert.False(t, d.Diff.Truncated)418 assert.False(t, d.Diff.Truncated)
418 })419 })
419 420
420 t.Run("custom limits and the byte limit", func(t *testing.T) {421 t.Run("custom limits and the byte limit", func(t *testing.T) {
421- f := newFixtureRepo(t, data, "limit-custom")422+ f := readertest.NewFixture(t, data, "limit-custom")
422 commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})423 commitFiles(f, "c", map[string]int{"a": 10, "b": 10, "c": 10})
423- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})424+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20})
424 require.NoError(t, err)425 require.NoError(t, err)
425 require.Len(t, d.Diff.Files, 2)426 require.Len(t, d.Diff.Files, 2)
426 assert.False(t, d.Diff.Files[0].Truncated)427 assert.False(t, d.Diff.Files[0].Truncated)
427 assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")428 assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left")
428 assert.Equal(t, 3, d.Stats.FilesChanged)429 assert.Equal(t, 3, d.Stats.FilesChanged)
429 430
430- d, err = g.GetCommit(ctx, f.name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})431+ d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50})
431 require.NoError(t, err)432 require.NoError(t, err)
432 require.Len(t, d.Diff.Files, 3)433 require.Len(t, d.Diff.Files, 3)
433 assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")434 assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50")
@@ -438,9 +439,9 @@ func TestDiffLimits(t *testing.T) {
438 })439 })
439 440
440 t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {441 t.Run("a very long line truncates its file instead of being cut", func(t *testing.T) {
441- f := newFixtureRepo(t, data, "limit-line")442+ f := readertest.NewFixture(t, data, "limit-line")
442- f.write("long", strings.Repeat("x", 200<<10)+"\n").write("short", "ok\n").commit("long")443+ f.Write("long", strings.Repeat("x", 200<<10)+"\n").Write("short", "ok\n").Commit("long")
443- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)444+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
444 require.NoError(t, err)445 require.NoError(t, err)
445 long, short := find(d.Diff, "long"), find(d.Diff, "short")446 long, short := find(d.Diff, "long"), find(d.Diff, "short")
446 assert.True(t, long.Truncated)447 assert.True(t, long.Truncated)
@@ -450,12 +451,12 @@ func TestDiffLimits(t *testing.T) {
450 })451 })
451 452
452 t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {453 t.Run("binary files and mode changes in a truncated commit", func(t *testing.T) {
453- f := newFixtureRepo(t, data, "limit-mixed")454+ f := readertest.NewFixture(t, data, "limit-mixed")
454- f.lines("a-big", "x", 2500).writeBytes("b-bin", []byte{0, 1, 2}).write("c-mode", "m\n").write("d-text", "t\n")455+ f.Lines("a-big", "x", 2500).WriteBytes("b-bin", []byte{0, 1, 2}).Write("c-mode", "m\n").Write("d-text", "t\n")
455- f.commit("v1")456+ f.Commit("v1")
456- f.lines("a-big", "y", 2500).writeBytes("b-bin", []byte{0, 3}).chmod("c-mode", 0o755).write("d-text", "t2\n")457+ f.Lines("a-big", "y", 2500).WriteBytes("b-bin", []byte{0, 3}).Chmod("c-mode", 0o755).Write("d-text", "t2\n")
457- id := f.commit("v2")458+ id := f.Commit("v2")
458- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, defaults)459+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
459 require.NoError(t, err)460 require.NoError(t, err)
460 assert.True(t, find(d.Diff, "a-big").Truncated)461 assert.True(t, find(d.Diff, "a-big").Truncated)
461 assert.True(t, find(d.Diff, "b-bin").Binary)462 assert.True(t, find(d.Diff, "b-bin").Binary)
@@ -463,7 +464,7 @@ func TestDiffLimits(t *testing.T) {
463 assert.False(t, find(d.Diff, "c-mode").Truncated)464 assert.False(t, find(d.Diff, "c-mode").Truncated)
464 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)465 assert.Len(t, find(d.Diff, "d-text").Hunks, 1)
465 // What is kept matches git.466 // What is kept matches git.
466- sections := f.showSections(id)467+ sections := showSections(f, id)
467 require.Len(t, sections, 4)468 require.Len(t, sections, 4)
468 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))469 assert.Equal(t, sections[3], unified(find(d.Diff, "d-text").Hunks))
469 })470 })
@@ -472,11 +473,11 @@ func TestDiffLimits(t *testing.T) {
472 func TestGetCommitStopsGitEarly(t *testing.T) {473 func TestGetCommitStopsGitEarly(t *testing.T) {
473 ctx := context.Background()474 ctx := context.Background()
474 g, data := newReader(t, nil)475 g, data := newReader(t, nil)
475- f := newFixtureRepo(t, data, "early")476+ f := readertest.NewFixture(t, data, "early")
476- f.lines("a", "x", 5).lines("z-huge", "y", 100000).commit("c")477+ f.Lines("a", "x", 5).Lines("z-huge", "y", 100000).Commit("c")
477 478
478 // z-huge is truncated by the numstat alone, so nothing of it is read.479 // z-huge is truncated by the numstat alone, so nothing of it is read.
479- d, err := g.GetCommit(ctx, f.name, domain.Revision{}, domain.DefaultDiffLimits())480+ d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits())
480 require.NoError(t, err)481 require.NoError(t, err)
481 require.Len(t, d.Diff.Files, 2)482 require.Len(t, d.Diff.Files, 2)
482 assert.False(t, d.Diff.Files[0].Truncated)483 assert.False(t, d.Diff.Files[0].Truncated)
D internal/adapters/gitcli/fixture_test.go +0 -158
@@ -1,158 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package gitcli_test
4-
5-import (
6- "fmt"
7- "math/rand/v2"
8- "os"
9- "os/exec"
10- "path/filepath"
11- "strings"
12- "testing"
13- "time"
14-
15- "github.com/stretchr/testify/require"
16-
17- "bnema.dev/ermite/internal/domain"
18-)
19-
20-// fixtureRepo builds a repository with real git: a bare repository in the
21-// data directory, and a work tree beside it that the helpers edit and commit.
22-type fixtureRepo struct {
23- t *testing.T
24- name domain.RepoName
25- bare string
26- work string
27- when time.Time
28-}
29-
30-// newFixtureRepo creates an empty bare repository repos/<name>.git whose HEAD
31-// is refs/heads/main.
32-func newFixtureRepo(t *testing.T, data, repo string) *fixtureRepo {
33- t.Helper()
34- f := &fixtureRepo{
35- t: t,
36- name: name(t, repo),
37- bare: filepath.Join(data, "repos", repo+".git"),
38- work: t.TempDir(),
39- when: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
40- }
41- out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.bare).CombinedOutput()
42- require.NoError(t, err, string(out))
43- f.git("config", "core.compression", "0")
44- f.git("config", "core.autocrlf", "false")
45- return f
46-}
47-
48-// git runs git against the bare repository and the work tree.
49-func (f *fixtureRepo) git(args ...string) string {
50- f.t.Helper()
51- cmd := exec.Command("git", args...)
52- cmd.Dir = f.work
53- cmd.Env = []string{
54- "PATH=" + os.Getenv("PATH"),
55- "HOME=" + f.work,
56- "GIT_CONFIG_NOSYSTEM=1",
57- "GIT_CONFIG_GLOBAL=/dev/null",
58- "GIT_DIR=" + f.bare,
59- "GIT_WORK_TREE=" + f.work,
60- "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org",
61- "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org",
62- "GIT_AUTHOR_DATE=" + f.when.Format(time.RFC3339),
63- "GIT_COMMITTER_DATE=" + f.when.Format(time.RFC3339),
64- }
65- out, err := cmd.CombinedOutput()
66- require.NoError(f.t, err, "git %s: %s", strings.Join(args, " "), out)
67- return strings.TrimSpace(string(out))
68-}
69-
70-func (f *fixtureRepo) path(p string) string { return filepath.Join(f.work, filepath.FromSlash(p)) }
71-
72-// write creates or replaces a file with the content.
73-func (f *fixtureRepo) write(p, content string) *fixtureRepo {
74- f.t.Helper()
75- require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
76- require.NoError(f.t, os.WriteFile(f.path(p), []byte(content), 0o644))
77- return f
78-}
79-
80-// writeBytes is write for binary content.
81-func (f *fixtureRepo) writeBytes(p string, content []byte) *fixtureRepo {
82- f.t.Helper()
83- require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
84- require.NoError(f.t, os.WriteFile(f.path(p), content, 0o644))
85- return f
86-}
87-
88-// lines writes n lines "<prefix> <i>" to the file.
89-func (f *fixtureRepo) lines(p, prefix string, n int) *fixtureRepo {
90- var b strings.Builder
91- for i := range n {
92- fmt.Fprintf(&b, "%s %d\n", prefix, i)
93- }
94- return f.write(p, b.String())
95-}
96-
97-func (f *fixtureRepo) remove(p string) *fixtureRepo {
98- f.t.Helper()
99- require.NoError(f.t, os.Remove(f.path(p)))
100- return f
101-}
102-
103-func (f *fixtureRepo) chmod(p string, mode os.FileMode) *fixtureRepo {
104- f.t.Helper()
105- require.NoError(f.t, os.Chmod(f.path(p), mode))
106- return f
107-}
108-
109-func (f *fixtureRepo) symlink(target, p string) *fixtureRepo {
110- f.t.Helper()
111- require.NoError(f.t, os.Symlink(target, f.path(p)))
112- return f
113-}
114-
115-func (f *fixtureRepo) mv(from, to string) *fixtureRepo {
116- f.t.Helper()
117- require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(to)), 0o755))
118- require.NoError(f.t, os.Rename(f.path(from), f.path(to)))
119- return f
120-}
121-
122-// commit stages everything and commits with the next timestamp; it returns
123-// the commit id.
124-func (f *fixtureRepo) commit(msg string) string {
125- f.t.Helper()
126- f.when = f.when.Add(time.Minute)
127- f.git("add", "-A")
128- f.git("commit", "--quiet", "--allow-empty", "-m", msg)
129- return f.git("rev-parse", "HEAD")
130-}
131-
132-// checkoutNew creates a branch from the current HEAD and switches to it.
133-func (f *fixtureRepo) checkoutNew(branch string) {
134- f.t.Helper()
135- f.git("checkout", "--quiet", "-b", branch)
136-}
137-
138-func (f *fixtureRepo) checkout(branch string) { f.t.Helper(); f.git("checkout", "--quiet", branch) }
139-
140-// merge merges the branch with a merge commit.
141-func (f *fixtureRepo) merge(branch string) string {
142- f.t.Helper()
143- f.when = f.when.Add(time.Minute)
144- f.git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch)
145- return f.git("rev-parse", "HEAD")
146-}
147-
148-// randomBytes returns n deterministic incompressible bytes.
149-func randomBytes(n int, seed uint64) []byte {
150- var s [32]byte
151- for i := range 8 {
152- s[i] = byte(seed >> (8 * i))
153- }
154- r := rand.NewChaCha8(s)
155- b := make([]byte, n)
156- _, _ = r.Read(b)
157- return b
158-}
M internal/adapters/gitcli/merge_test.go +51 -50
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/readertest"
14 )15 )
15 16
16 func oidOf(t *testing.T, s string) domain.ObjectID {17 func oidOf(t *testing.T, s string) domain.ObjectID {
@@ -22,25 +23,25 @@ func oidOf(t *testing.T, s string) domain.ObjectID {
22 23
23 // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat.24 // mergeFixture is main (a, b) and feat (a, b, c, d) with a rename on feat.
24 type mergeFixture struct {25 type mergeFixture struct {
25- f *fixtureRepo26+ f *readertest.Fixture
26 a, b, c, d domain.ObjectID27 a, b, c, d domain.ObjectID
27 mainTip, featTip domain.ObjectID28 mainTip, featTip domain.ObjectID
28 }29 }
29 30
30 func newMergeFixture(t *testing.T, data string) mergeFixture {31 func newMergeFixture(t *testing.T, data string) mergeFixture {
31 t.Helper()32 t.Helper()
32- f := newFixtureRepo(t, data, "proj")33+ f := readertest.NewFixture(t, data, "proj")
33- f.write("a.txt", "one\ntwo\nthree\n")34+ f.Write("a.txt", "one\ntwo\nthree\n")
34- a := f.commit("add a")35+ a := f.Commit("add a")
35- f.write("b.txt", "bee\n")36+ f.Write("b.txt", "bee\n")
36- b := f.commit("add b")37+ b := f.Commit("add b")
37- f.git("update-ref", "refs/heads/main", b)38+ f.Git("update-ref", "refs/heads/main", b)
38- f.checkoutNew("feat")39+ f.CheckoutNew("feat")
39- f.write("a.txt", "one\n2\nthree\n")40+ f.Write("a.txt", "one\n2\nthree\n")
40- c := f.commit("change a")41+ c := f.Commit("change a")
41- f.write("c.txt", "see\n")42+ f.Write("c.txt", "see\n")
42- d := f.commit("add c")43+ d := f.Commit("add c")
43- f.git("update-ref", "refs/heads/feat", d)44+ f.Git("update-ref", "refs/heads/feat", d)
44 return mergeFixture{45 return mergeFixture{
45 f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d),46 f: f, a: oidOf(t, a), b: oidOf(t, b), c: oidOf(t, c), d: oidOf(t, d),
46 mainTip: oidOf(t, b), featTip: oidOf(t, d),47 mainTip: oidOf(t, b), featTip: oidOf(t, d),
@@ -53,27 +54,27 @@ func TestBranchHeadAndHasCommit(t *testing.T) {
53 m := newMergeFixture(t, data)54 m := newMergeFixture(t, data)
54 t.Cleanup(func() { _ = g.Close(ctx) })55 t.Cleanup(func() { _ = g.Close(ctx) })
55 56
56- got, err := g.BranchHead(ctx, m.f.name, "feat")57+ got, err := g.BranchHead(ctx, m.f.Name, "feat")
57 require.NoError(t, err)58 require.NoError(t, err)
58 assert.Equal(t, m.d, got)59 assert.Equal(t, m.d, got)
59 60
60 for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} {61 for _, branch := range []string{"nope", "", "-x", "a..b", "refs/heads/feat", "feat^{tree}"} {
61- _, err = g.BranchHead(ctx, m.f.name, branch)62+ _, err = g.BranchHead(ctx, m.f.Name, branch)
62 assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch)63 assert.ErrorIs(t, err, domain.ErrNotFound, "%q", branch)
63 }64 }
64 _, err = g.BranchHead(ctx, name(t, "missing"), "main")65 _, err = g.BranchHead(ctx, name(t, "missing"), "main")
65 assert.ErrorIs(t, err, domain.ErrNotFound)66 assert.ErrorIs(t, err, domain.ErrNotFound)
66 67
67- ok, err := g.HasCommit(ctx, m.f.name, m.c)68+ ok, err := g.HasCommit(ctx, m.f.Name, m.c)
68 require.NoError(t, err)69 require.NoError(t, err)
69 assert.True(t, ok)70 assert.True(t, ok)
70- ok, err = g.HasCommit(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40)))71+ ok, err = g.HasCommit(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)))
71 require.NoError(t, err)72 require.NoError(t, err)
72 assert.False(t, ok)73 assert.False(t, ok)
73 // A tree is not a commit.74 // A tree is not a commit.
74- tree, err := g.CommitTree(ctx, m.f.name, m.d)75+ tree, err := g.CommitTree(ctx, m.f.Name, m.d)
75 require.NoError(t, err)76 require.NoError(t, err)
76- ok, err = g.HasCommit(ctx, m.f.name, tree)77+ ok, err = g.HasCommit(ctx, m.f.Name, tree)
77 require.NoError(t, err)78 require.NoError(t, err)
78 assert.False(t, ok)79 assert.False(t, ok)
79 }80 }
@@ -83,23 +84,23 @@ func TestMergeBaseAndAncestry(t *testing.T) {
83 ctx := context.Background()84 ctx := context.Background()
84 m := newMergeFixture(t, data)85 m := newMergeFixture(t, data)
85 // A branch that diverged from a.86 // A branch that diverged from a.
86- m.f.git("checkout", "--quiet", "-b", "side", m.a.String())87+ m.f.Git("checkout", "--quiet", "-b", "side", m.a.String())
87- m.f.write("s.txt", "side\n")88+ m.f.Write("s.txt", "side\n")
88- side := oidOf(t, m.f.commit("side"))89+ side := oidOf(t, m.f.Commit("side"))
89- m.f.git("update-ref", "refs/heads/side", side.String())90+ m.f.Git("update-ref", "refs/heads/side", side.String())
90 // An unrelated root.91 // An unrelated root.
91- m.f.git("checkout", "--quiet", "--orphan", "other")92+ m.f.Git("checkout", "--quiet", "--orphan", "other")
92- m.f.git("rm", "-rf", "--quiet", ".")93+ m.f.Git("rm", "-rf", "--quiet", ".")
93- m.f.write("o.txt", "other\n")94+ m.f.Write("o.txt", "other\n")
94- other := oidOf(t, m.f.commit("other root"))95+ other := oidOf(t, m.f.Commit("other root"))
95 96
96- base, err := g.MergeBase(ctx, m.f.name, m.mainTip, m.featTip)97+ base, err := g.MergeBase(ctx, m.f.Name, m.mainTip, m.featTip)
97 require.NoError(t, err)98 require.NoError(t, err)
98 assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base")99 assert.Equal(t, m.b, base, "main is an ancestor of feat: it is the base")
99- base, err = g.MergeBase(ctx, m.f.name, side, m.featTip)100+ base, err = g.MergeBase(ctx, m.f.Name, side, m.featTip)
100 require.NoError(t, err)101 require.NoError(t, err)
101 assert.Equal(t, m.a, base)102 assert.Equal(t, m.a, base)
102- _, err = g.MergeBase(ctx, m.f.name, other, m.featTip)103+ _, err = g.MergeBase(ctx, m.f.Name, other, m.featTip)
103 assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history")104 assert.ErrorIs(t, err, domain.ErrNotFound, "no shared history")
104 105
105 for _, tc := range []struct {106 for _, tc := range []struct {
@@ -112,11 +113,11 @@ func TestMergeBaseAndAncestry(t *testing.T) {
112 {side, m.featTip, false},113 {side, m.featTip, false},
113 {other, m.featTip, false},114 {other, m.featTip, false},
114 } {115 } {
115- got, err := g.IsAncestor(ctx, m.f.name, tc.anc, tc.desc)116+ got, err := g.IsAncestor(ctx, m.f.Name, tc.anc, tc.desc)
116 require.NoError(t, err)117 require.NoError(t, err)
117 assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc)118 assert.Equal(t, tc.want, got, "%s -> %s", tc.anc, tc.desc)
118 }119 }
119- _, err = g.IsAncestor(ctx, m.f.name, oidOf(t, strings.Repeat("1", 40)), m.featTip)120+ _, err = g.IsAncestor(ctx, m.f.Name, oidOf(t, strings.Repeat("1", 40)), m.featTip)
120 assert.Error(t, err, "an unknown commit is an error, not a plain no")121 assert.Error(t, err, "an unknown commit is an error, not a plain no")
121 }122 }
122 123
@@ -125,7 +126,7 @@ func TestListRange(t *testing.T) {
125 ctx := context.Background()126 ctx := context.Background()
126 m := newMergeFixture(t, data)127 m := newMergeFixture(t, data)
127 128
128- commits, more, err := g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 10)129+ commits, more, err := g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 10)
129 require.NoError(t, err)130 require.NoError(t, err)
130 assert.False(t, more)131 assert.False(t, more)
131 require.Len(t, commits, 2)132 require.Len(t, commits, 2)
@@ -133,21 +134,21 @@ func TestListRange(t *testing.T) {
133 assert.Equal(t, m.c, commits[1].ID)134 assert.Equal(t, m.c, commits[1].ID)
134 assert.Equal(t, "add c", commits[0].Summary)135 assert.Equal(t, "add c", commits[0].Summary)
135 136
136- commits, more, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 1)137+ commits, more, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 1)
137 require.NoError(t, err)138 require.NoError(t, err)
138 assert.True(t, more)139 assert.True(t, more)
139 require.Len(t, commits, 1)140 require.Len(t, commits, 1)
140 141
141- commits, more, err = g.ListRange(ctx, m.f.name, domain.ObjectID{}, m.featTip, 10)142+ commits, more, err = g.ListRange(ctx, m.f.Name, domain.ObjectID{}, m.featTip, 10)
142 require.NoError(t, err)143 require.NoError(t, err)
143 assert.False(t, more)144 assert.False(t, more)
144 assert.Len(t, commits, 4, "a zero exclude lists the whole history")145 assert.Len(t, commits, 4, "a zero exclude lists the whole history")
145 146
146- commits, _, err = g.ListRange(ctx, m.f.name, m.featTip, m.mainTip, 10)147+ commits, _, err = g.ListRange(ctx, m.f.Name, m.featTip, m.mainTip, 10)
147 require.NoError(t, err)148 require.NoError(t, err)
148 assert.Empty(t, commits)149 assert.Empty(t, commits)
149 150
150- _, _, err = g.ListRange(ctx, m.f.name, m.mainTip, m.featTip, 0)151+ _, _, err = g.ListRange(ctx, m.f.Name, m.mainTip, m.featTip, 0)
151 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)152 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
152 }153 }
153 154
@@ -156,7 +157,7 @@ func TestDiffCommits(t *testing.T) {
156 ctx := context.Background()157 ctx := context.Background()
157 m := newMergeFixture(t, data)158 m := newMergeFixture(t, data)
158 159
159- stats, diff, err := g.DiffCommits(ctx, m.f.name, m.mainTip, m.featTip, domain.DefaultDiffLimits())160+ stats, diff, err := g.DiffCommits(ctx, m.f.Name, m.mainTip, m.featTip, domain.DefaultDiffLimits())
160 require.NoError(t, err)161 require.NoError(t, err)
161 assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats)162 assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, stats)
162 require.Len(t, diff.Files, 2)163 require.Len(t, diff.Files, 2)
@@ -168,7 +169,7 @@ func TestDiffCommits(t *testing.T) {
168 assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1))169 assert.False(t, diff.HasLine("c.txt", domain.SideOld, 1))
169 170
170 // The diff between two commits of a branch.171 // The diff between two commits of a branch.
171- stats, _, err = g.DiffCommits(ctx, m.f.name, m.c, m.featTip, domain.DefaultDiffLimits())172+ stats, _, err = g.DiffCommits(ctx, m.f.Name, m.c, m.featTip, domain.DefaultDiffLimits())
172 require.NoError(t, err)173 require.NoError(t, err)
173 assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats)174 assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, stats)
174 }175 }
@@ -178,29 +179,29 @@ func TestWriteCommitAndUpdateBranch(t *testing.T) {
178 ctx := context.Background()179 ctx := context.Background()
179 m := newMergeFixture(t, data)180 m := newMergeFixture(t, data)
180 181
181- tree, err := g.CommitTree(ctx, m.f.name, m.featTip)182+ tree, err := g.CommitTree(ctx, m.f.Name, m.featTip)
182 require.NoError(t, err)183 require.NoError(t, err)
183- assert.Equal(t, m.f.git("rev-parse", m.featTip.String()+"^{tree}"), tree.String())184+ assert.Equal(t, m.f.Git("rev-parse", m.featTip.String()+"^{tree}"), tree.String())
184 185
185 obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" +186 obj := "tree " + tree.String() + "\nparent " + m.mainTip.String() + "\n" +
186 "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n"187 "author A <a@x.org> 1 +0000\ncommitter C <c@x.org> 2 +0000\n\nsquashed\n"
187- id, err := g.WriteCommit(ctx, m.f.name, []byte(obj))188+ id, err := g.WriteCommit(ctx, m.f.Name, []byte(obj))
188 require.NoError(t, err)189 require.NoError(t, err)
189- assert.Equal(t, "commit", m.f.git("cat-file", "-t", id.String()))190+ assert.Equal(t, "commit", m.f.Git("cat-file", "-t", id.String()))
190- assert.Equal(t, "squashed", m.f.git("log", "-1", "--format=%s", id.String()))191+ assert.Equal(t, "squashed", m.f.Git("log", "-1", "--format=%s", id.String()))
191 192
192- _, err = g.WriteCommit(ctx, m.f.name, []byte("not a commit"))193+ _, err = g.WriteCommit(ctx, m.f.Name, []byte("not a commit"))
193 assert.Error(t, err, "git checks the object")194 assert.Error(t, err, "git checks the object")
194 195
195 // The old value is a precondition.196 // The old value is a precondition.
196- err = g.UpdateBranch(ctx, m.f.name, "main", m.featTip, id)197+ err = g.UpdateBranch(ctx, m.f.Name, "main", m.featTip, id)
197 assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat")198 assert.ErrorIs(t, err, domain.ErrConflict, "main is not at feat")
198- assert.Equal(t, m.mainTip.String(), m.f.git("rev-parse", "refs/heads/main"))199+ assert.Equal(t, m.mainTip.String(), m.f.Git("rev-parse", "refs/heads/main"))
199 200
200- require.NoError(t, g.UpdateBranch(ctx, m.f.name, "main", m.mainTip, id))201+ require.NoError(t, g.UpdateBranch(ctx, m.f.Name, "main", m.mainTip, id))
201- assert.Equal(t, id.String(), m.f.git("rev-parse", "refs/heads/main"))202+ assert.Equal(t, id.String(), m.f.Git("rev-parse", "refs/heads/main"))
202 203
203- err = g.UpdateBranch(ctx, m.f.name, "refs/heads/main", id, m.mainTip)204+ err = g.UpdateBranch(ctx, m.f.Name, "refs/heads/main", id, m.mainTip)
204 assert.Error(t, err, "only short branch names")205 assert.Error(t, err, "only short branch names")
205 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)206 err = g.UpdateBranch(ctx, name(t, "missing"), "main", id, m.mainTip)
206 assert.ErrorIs(t, err, domain.ErrNotFound)207 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/adapters/gitcli/pool_test.go +47 -46
@@ -20,6 +20,7 @@ import (
20 20
21 "bnema.dev/ermite/internal/adapters/gitcli"21 "bnema.dev/ermite/internal/adapters/gitcli"
22 "bnema.dev/ermite/internal/domain"22 "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/readertest"
23 )24 )
24 25
25 // alive reports whether a process still exists. The adapter collects its26 // alive reports whether a process still exists. The adapter collects its
@@ -43,17 +44,17 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
43 // The default idle timeout (60 s) never fires during the test: the hooks44 // The default idle timeout (60 s) never fires during the test: the hooks
44 // do what the timers would, deterministically.45 // do what the timers would, deterministically.
45 g, data := newReader(t, nil)46 g, data := newReader(t, nil)
46- h := newHistory(t, data)47+ h := readertest.NewHistory(t, data)
47 48
48 assert.Empty(t, g.CatFilePIDs(), "nothing runs before the first read")49 assert.Empty(t, g.CatFilePIDs(), "nothing runs before the first read")
49- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})50+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
50 require.NoError(t, err)51 require.NoError(t, err)
51 pids := g.CatFilePIDs()52 pids := g.CatFilePIDs()
52 require.Len(t, pids, 1)53 require.Len(t, pids, 1)
53 require.True(t, alive(pids[0]))54 require.True(t, alive(pids[0]))
54 55
55 for range 5 {56 for range 5 {
56- _, err = g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false)57+ _, err = g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false)
57 require.NoError(t, err)58 require.NoError(t, err)
58 }59 }
59 assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request")60 assert.Equal(t, pids, g.CatFilePIDs(), "one long-lived process serves every request")
@@ -68,7 +69,7 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
68 requireGone(t, pids...)69 requireGone(t, pids...)
69 70
70 // The next request starts a new one.71 // The next request starts a new one.
71- _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})72+ _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
72 require.NoError(t, err)73 require.NoError(t, err)
73 next := g.CatFilePIDs()74 next := g.CatFilePIDs()
74 require.Len(t, next, 1)75 require.Len(t, next, 1)
@@ -78,8 +79,8 @@ func TestCatFileProcessIsReusedAndStopsWhenIdle(t *testing.T) {
78 func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) {79 func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) {
79 ctx := context.Background()80 ctx := context.Background()
80 g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 20 * time.Millisecond })81 g, data := newReader(t, func(c *gitcli.Config) { c.CatFileIdle = 20 * time.Millisecond })
81- h := newHistory(t, data)82+ h := readertest.NewHistory(t, data)
82- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})83+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
83 require.NoError(t, err)84 require.NoError(t, err)
84 pids := g.CatFilePIDs()85 pids := g.CatFilePIDs()
85 require.Eventually(t, func() bool { return len(g.CatFilePIDs()) == 0 }, 5*time.Second, 5*time.Millisecond)86 require.Eventually(t, func() bool { return len(g.CatFilePIDs()) == 0 }, 5*time.Second, 5*time.Millisecond)
@@ -89,17 +90,17 @@ func TestCatFileIdleTimerStopsAnUnusedProcess(t *testing.T) {
89 func TestCloseStopsEveryProcess(t *testing.T) {90 func TestCloseStopsEveryProcess(t *testing.T) {
90 ctx := context.Background()91 ctx := context.Background()
91 g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them92 g, data := newReader(t, nil) // default idle: 60 s, so only Close can stop them
92- h := newHistory(t, data)93+ h := readertest.NewHistory(t, data)
93- f2 := newFixtureRepo(t, data, "second")94+ f2 := readertest.NewFixture(t, data, "second")
94- f2.write("x", "x").commit("x")95+ f2.Write("x", "x").Commit("x")
95- big := newFixtureRepo(t, data, "bigone")96+ big := readertest.NewFixture(t, data, "bigone")
96- big.writeBytes("big", randomBytes(600<<10, 1)).commit("big")97+ big.WriteBytes("big", readertest.RandomBytes(600<<10, 1)).Commit("big")
97 98
98- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})99+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
99 require.NoError(t, err)100 require.NoError(t, err)
100- _, err = g.ResolveRevision(ctx, f2.name, domain.Revision{})101+ _, err = g.ResolveRevision(ctx, f2.Name, domain.Revision{})
101 require.NoError(t, err)102 require.NoError(t, err)
102- _, rc, err := g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big"))103+ _, rc, err := g.OpenBlob(ctx, big.Name, domain.Revision{}, readertest.TreePath(t, "big"))
103 require.NoError(t, err)104 require.NoError(t, err)
104 _ = rc // left open on purpose: Close must stop its process too105 _ = rc // left open on purpose: Close must stop its process too
105 106
@@ -115,9 +116,9 @@ func TestCloseStopsEveryProcess(t *testing.T) {
115 requireGone(t, pids...)116 requireGone(t, pids...)
116 assert.Empty(t, g.CatFilePIDs())117 assert.Empty(t, g.CatFilePIDs())
117 118
118- _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})119+ _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
119 assert.ErrorIs(t, err, gitcli.ErrClosed)120 assert.ErrorIs(t, err, gitcli.ErrClosed)
120- _, _, err = g.OpenBlob(ctx, big.name, domain.Revision{}, tpath(t, "big"))121+ _, _, err = g.OpenBlob(ctx, big.Name, domain.Revision{}, readertest.TreePath(t, "big"))
121 assert.ErrorIs(t, err, gitcli.ErrClosed)122 assert.ErrorIs(t, err, gitcli.ErrClosed)
122 assert.NoError(t, g.Close(ctx), "Close is idempotent")123 assert.NoError(t, g.Close(ctx), "Close is idempotent")
123 _, err = io.Copy(io.Discard, rc)124 _, err = io.Copy(io.Discard, rc)
@@ -131,9 +132,9 @@ func TestPoolIsBounded(t *testing.T) {
131 g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 })132 g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 2 })
132 var names []domain.RepoName133 var names []domain.RepoName
133 for i := range 6 {134 for i := range 6 {
134- f := newFixtureRepo(t, data, fmt.Sprintf("r%d", i))135+ f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i))
135- f.write("x", "x").commit("x")136+ f.Write("x", "x").Commit("x")
136- names = append(names, f.name)137+ names = append(names, f.Name)
137 }138 }
138 var seen []int139 var seen []int
139 for _, n := range names {140 for _, n := range names {
@@ -158,9 +159,9 @@ func TestPoolIsBounded(t *testing.T) {
158 func TestPoolRestartsAfterProcessDeath(t *testing.T) {159 func TestPoolRestartsAfterProcessDeath(t *testing.T) {
159 ctx := context.Background()160 ctx := context.Background()
160 g, data := newReader(t, nil)161 g, data := newReader(t, nil)
161- h := newHistory(t, data)162+ h := readertest.NewHistory(t, data)
162 163
163- _, err := g.ResolveRevision(ctx, h.name, domain.Revision{})164+ _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
164 require.NoError(t, err)165 require.NoError(t, err)
165 old := g.CatFilePIDs()166 old := g.CatFilePIDs()
166 require.Len(t, old, 1)167 require.Len(t, old, 1)
@@ -168,9 +169,9 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
168 requireGone(t, old[0])169 requireGone(t, old[0])
169 170
170 // The request that finds the dead process succeeds on a fresh one.171 // The request that finds the dead process succeeds on a fresh one.
171- got, err := g.ResolveRevision(ctx, h.name, domain.Revision{})172+ got, err := g.ResolveRevision(ctx, h.Name, domain.Revision{})
172 require.NoError(t, err)173 require.NoError(t, err)
173- assert.Equal(t, h.merge, got.CommitID.String())174+ assert.Equal(t, h.Merge, got.CommitID.String())
174 now := g.CatFilePIDs()175 now := g.CatFilePIDs()
175 require.Len(t, now, 1)176 require.Len(t, now, 1)
176 assert.NotEqual(t, old, now)177 assert.NotEqual(t, old, now)
@@ -179,7 +180,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) {
179 func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {180 func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
180 ctx := context.Background()181 ctx := context.Background()
181 g, data := newReader(t, nil)182 g, data := newReader(t, nil)
182- h := newHistory(t, data)183+ h := readertest.NewHistory(t, data)
183 184
184 var wg sync.WaitGroup185 var wg sync.WaitGroup
185 errs := make(chan error, 24*10)186 errs := make(chan error, 24*10)
@@ -188,13 +189,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
188 for j := range 10 {189 for j := range 10 {
189 switch (i + j) % 4 {190 switch (i + j) % 4 {
190 case 0:191 case 0:
191- res, err := g.ResolveRevision(ctx, h.name, rev(t, "feature/x"))192+ res, err := g.ResolveRevision(ctx, h.Name, readertest.Rev(t, "feature/x"))
192- if err == nil && res.CommitID.String() != h.side {193+ if err == nil && res.CommitID.String() != h.Side {
193 err = fmt.Errorf("resolved %s", res.CommitID)194 err = fmt.Errorf("resolved %s", res.CommitID)
194 }195 }
195 errs <- err196 errs <- err
196 case 1:197 case 1:
197- info, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"))198+ info, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, readertest.TreePath(t, "b.txt"))
198 if err == nil {199 if err == nil {
199 var body []byte200 var body []byte
200 body, err = io.ReadAll(rc)201 body, err = io.ReadAll(rc)
@@ -205,13 +206,13 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
205 }206 }
206 errs <- err207 errs <- err
207 case 2:208 case 2:
208- tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false)209+ tree, err := g.GetTree(ctx, h.Name, readertest.Rev(t, "v2"), readertest.TreePath(t, "d"), false)
209 if err == nil && len(tree.Entries) != 2 {210 if err == nil && len(tree.Entries) != 2 {
210 err = fmt.Errorf("tree of %d entries", len(tree.Entries))211 err = fmt.Errorf("tree of %d entries", len(tree.Entries))
211 }212 }
212 errs <- err213 errs <- err
213 default:214 default:
214- _, err := g.ResolveRefPath(ctx, h.name, "feature/x/d/f")215+ _, err := g.ResolveRefPath(ctx, h.Name, "feature/x/d/f")
215 errs <- err216 errs <- err
216 }217 }
217 }218 }
@@ -227,7 +228,7 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) {
227 228
228 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {229 func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
229 g, data := newReader(t, nil)230 g, data := newReader(t, nil)
230- h := newHistory(t, data)231+ h := readertest.NewHistory(t, data)
231 232
232 // Many requests, some with a context that dies at a random point. Whatever233 // Many requests, some with a context that dies at a random point. Whatever
233 // state a killed process was in, later requests must get right answers.234 // state a killed process was in, later requests must get right answers.
@@ -236,10 +237,10 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
236 wg.Go(func() {237 wg.Go(func() {
237 for j := range 20 {238 for j := range 20 {
238 ctx, cancel := context.WithTimeout(context.Background(), time.Duration((i*7+j*3)%5)*time.Millisecond)239 ctx, cancel := context.WithTimeout(context.Background(), time.Duration((i*7+j*3)%5)*time.Millisecond)
239- res, err := g.ResolveRevision(ctx, h.name, rev(t, "v2"))240+ res, err := g.ResolveRevision(ctx, h.Name, readertest.Rev(t, "v2"))
240 cancel()241 cancel()
241 if err == nil {242 if err == nil {
242- assert.Equal(t, h.c2, res.CommitID.String())243+ assert.Equal(t, h.C2, res.CommitID.String())
243 } else {244 } else {
244 assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err)245 assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled), "%v", err)
245 }246 }
@@ -247,18 +248,18 @@ func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) {
247 })248 })
248 }249 }
249 wg.Wait()250 wg.Wait()
250- res, err := g.ResolveRevision(context.Background(), h.name, rev(t, "v2"))251+ res, err := g.ResolveRevision(context.Background(), h.Name, readertest.Rev(t, "v2"))
251 require.NoError(t, err)252 require.NoError(t, err)
252- assert.Equal(t, h.c2, res.CommitID.String())253+ assert.Equal(t, h.C2, res.CommitID.String())
253 }254 }
254 255
255 func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {256 func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
256 g, data := newReader(t, nil)257 g, data := newReader(t, nil)
257- f := newFixtureRepo(t, data, "bigone")258+ f := readertest.NewFixture(t, data, "bigone")
258- f.writeBytes("big", randomBytes(2<<20, 3)).commit("big")259+ f.WriteBytes("big", readertest.RandomBytes(2<<20, 3)).Commit("big")
259 260
260 ctx, cancel := context.WithCancel(context.Background())261 ctx, cancel := context.WithCancel(context.Background())
261- _, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big"))262+ _, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big"))
262 require.NoError(t, err)263 require.NoError(t, err)
263 pids := g.BlobStreamPIDs()264 pids := g.BlobStreamPIDs()
264 require.Len(t, pids, 1)265 require.Len(t, pids, 1)
@@ -273,7 +274,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
273 assert.Equal(t, 0, g.BlobStreams())274 assert.Equal(t, 0, g.BlobStreams())
274 275
275 // Closing early stops the process as well.276 // Closing early stops the process as well.
276- _, rc, err = g.OpenBlob(context.Background(), f.name, domain.Revision{}, tpath(t, "big"))277+ _, rc, err = g.OpenBlob(context.Background(), f.Name, domain.Revision{}, readertest.TreePath(t, "big"))
277 require.NoError(t, err)278 require.NoError(t, err)
278 pids = g.BlobStreamPIDs()279 pids = g.BlobStreamPIDs()
279 require.Len(t, pids, 1)280 require.Len(t, pids, 1)
@@ -284,7 +285,7 @@ func TestBlobStreamStopsWhenContextEndsOrReaderCloses(t *testing.T) {
284 // A context that is already cancelled starts nothing.285 // A context that is already cancelled starts nothing.
285 cctx, cancel2 := context.WithCancel(context.Background())286 cctx, cancel2 := context.WithCancel(context.Background())
286 cancel2()287 cancel2()
287- _, _, err = g.OpenBlob(cctx, f.name, domain.Revision{}, tpath(t, "big"))288+ _, _, err = g.OpenBlob(cctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big"))
288 assert.ErrorIs(t, err, context.Canceled)289 assert.ErrorIs(t, err, context.Canceled)
289 assert.Equal(t, 0, g.BlobStreams())290 assert.Equal(t, 0, g.BlobStreams())
290 }291 }
@@ -299,18 +300,18 @@ func TestOpenBlobStreams50MiB(t *testing.T) {
299 }300 }
300 ctx := context.Background()301 ctx := context.Background()
301 g, data := newReader(t, nil)302 g, data := newReader(t, nil)
302- f := newFixtureRepo(t, data, "huge")303+ f := readertest.NewFixture(t, data, "huge")
303 const size = 50 << 20304 const size = 50 << 20
304- content := randomBytes(size, 42)305+ content := readertest.RandomBytes(size, 42)
305 want := sha256.Sum256(content)306 want := sha256.Sum256(content)
306- f.writeBytes("huge.bin", content).commit("huge")307+ f.WriteBytes("huge.bin", content).Commit("huge")
307 content = nil //nolint:ineffassign // let the 50 MiB go before measuring308 content = nil //nolint:ineffassign // let the 50 MiB go before measuring
308 309
309 runtime.GC()310 runtime.GC()
310 var before, after runtime.MemStats311 var before, after runtime.MemStats
311 runtime.ReadMemStats(&before)312 runtime.ReadMemStats(&before)
312 313
313- info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "huge.bin"))314+ info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "huge.bin"))
314 require.NoError(t, err)315 require.NoError(t, err)
315 assert.Equal(t, int64(size), info.Size)316 assert.Equal(t, int64(size), info.Size)
316 assert.True(t, info.Binary)317 assert.True(t, info.Binary)
@@ -383,10 +384,10 @@ func TestExecSemaphoreBoundsOneShotCommands(t *testing.T) {
383 func TestBlobStreamsAreBounded(t *testing.T) {384 func TestBlobStreamsAreBounded(t *testing.T) {
384 ctx := context.Background()385 ctx := context.Background()
385 g, data := newReader(t, func(c *gitcli.Config) { c.MaxBlobStreams = 2 })386 g, data := newReader(t, func(c *gitcli.Config) { c.MaxBlobStreams = 2 })
386- f := newFixtureRepo(t, data, "bigone")387+ f := readertest.NewFixture(t, data, "bigone")
387- f.writeBytes("big", randomBytes(600<<10, 5)).commit("big")388+ f.WriteBytes("big", readertest.RandomBytes(600<<10, 5)).Commit("big")
388 open := func() (io.ReadCloser, error) {389 open := func() (io.ReadCloser, error) {
389- _, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big"))390+ _, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big"))
390 return rc, err391 return rc, err
391 }392 }
392 393
M internal/adapters/gitcli/read.go +2 -0
@@ -235,6 +235,8 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
236 // first, with up to one `cat-file` request per candidate on the repository's236 // first, with up to one `cat-file` request per candidate on the repository's
237 // process. At most domain.MaxRefPathTries segments are tried.237 // process. At most domain.MaxRefPathTries segments are tried.
238+//
239+//hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection
238 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {240 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
239 dir, err := g.repoDir(repo)241 dir, err := g.repoDir(repo)
240 if err != nil {242 if err != nil {
A internal/readertest/compare.go +343 -0
@@ -0,0 +1,343 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Budget bounds how many revisions and paths Compare asks about, so that it
23+// stays fast on a large repository.
24+type Budget struct {
25+ Revs int // revisions (refs and commits) for trees and blobs
26+ LogRevs int // revisions for history pages
27+ Dirs int // directories per revision
28+ Files int // files per revision
29+ LogPaths int // paths for path-limited history
30+ LastTrees int // trees listed with last commits
31+}
32+
33+// SmallBudget suits the fixtures.
34+var SmallBudget = Budget{Revs: 24, LogRevs: 8, Dirs: 10, Files: 10, LogPaths: 6, LastTrees: 6}
35+
36+// LargeBudget suits a repository of thousands of commits.
37+var LargeBudget = Budget{Revs: 4, LogRevs: 2, Dirs: 6, Files: 8, LogPaths: 5, LastTrees: 3}
38+
39+// errClass names the kind of an error of the port, so that two readers agree
40+// on what went wrong without agreeing on the message.
41+func errClass(err error) string {
42+ switch {
43+ case err == nil:
44+ return "ok"
45+ case errors.Is(err, domain.ErrNotFound):
46+ return "not found"
47+ case errors.Is(err, domain.ErrNotFile):
48+ return "not a file"
49+ case errors.Is(err, domain.ErrNotDirectory):
50+ return "not a directory"
51+ case errors.Is(err, domain.ErrInvalidPageSize):
52+ return "invalid page size"
53+ case errors.Is(err, domain.ErrInvalidPath):
54+ return "invalid path"
55+ case errors.Is(err, context.Canceled):
56+ return "canceled"
57+ }
58+ return "other: " + err.Error()
59+}
60+
61+// Queries are the questions put to both readers about one repository.
62+type Queries struct {
63+ Revs []string // revisions as a client writes them
64+ LogRevs []string
65+ Dirs []string
66+ Files []string
67+ LogPaths []string
68+ RefPaths []string
69+}
70+
71+// GitOut runs git in dir and returns its trimmed output.
72+func GitOut(t testing.TB, dir string, args ...string) string {
73+ t.Helper()
74+ cmd := exec.Command("git", append([]string{"--git-dir=" + dir}, args...)...)
75+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "LC_ALL=C"}
76+ out, err := cmd.Output()
77+ require.NoError(t, err, "git %s", strings.Join(args, " "))
78+ return strings.TrimRight(string(out), "\n")
79+}
80+
81+func lines(s string) []string {
82+ if s == "" {
83+ return nil
84+ }
85+ return strings.Split(s, "\n")
86+}
87+
88+// spread picks at most n elements of s, evenly, always the first.
89+func spread[T any](s []T, n int) []T {
90+ if len(s) <= n || n <= 0 {
91+ return s
92+ }
93+ out := make([]T, 0, n)
94+ for i := range n {
95+ out = append(out, s[i*len(s)/n])
96+ }
97+ return out
98+}
99+
100+// Derive builds the questions for the repository in dir with git.
101+func Derive(t testing.TB, dir string, b Budget) Queries {
102+ t.Helper()
103+ var q Queries
104+ revs := []string{"", "HEAD", "nope", "deadbeefdeadbeef", strings.Repeat("ab", 20), "refs/heads", "refs/nope/x"}
105+ for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")) {
106+ revs = append(revs, l)
107+ if short, ok := strings.CutPrefix(l, "refs/heads/"); ok {
108+ revs = append(revs, short)
109+ } else if short, ok := strings.CutPrefix(l, "refs/tags/"); ok {
110+ revs = append(revs, short)
111+ }
112+ }
113+ for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags")) {
114+ revs = append(revs, l, l[:7], l[:12]) // tag objects too: they are not commits when they lead elsewhere
115+ }
116+ var commits []string
117+ {
118+ // An empty repository has no commit: rev-list --all prints nothing.
119+ cmd := exec.Command("git", "--git-dir="+dir, "rev-list", "--all")
120+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
121+ out, err := cmd.Output()
122+ require.NoError(t, err)
123+ commits = lines(strings.TrimRight(string(out), "\n"))
124+ }
125+ for _, c := range spread(commits, b.Revs) {
126+ revs = append(revs, c, c[:7], c[:10])
127+ }
128+ if len(commits) > 0 {
129+ tree := GitOut(t, dir, "rev-parse", commits[0]+"^{tree}")
130+ revs = append(revs, tree, tree[:8]) // a tree is not a commit
131+ }
132+ q.Revs = dedupe(revs)
133+ var logRevs []string
134+ for _, r := range q.Revs {
135+ if r == "" || r == "HEAD" || strings.HasPrefix(r, "refs/") || len(r) == 40 {
136+ logRevs = append(logRevs, r)
137+ }
138+ }
139+ q.LogRevs = spread(logRevs, b.LogRevs+4)
140+
141+ // Paths of the newest commit of HEAD, or of the first commit.
142+ if len(commits) > 0 {
143+ var dirs, files []string
144+ for _, l := range lines(GitOut(t, dir, "ls-tree", "-r", "-t", "--format=%(objecttype) %(path)", commits[0])) {
145+ typ, p, _ := strings.Cut(l, " ")
146+ switch typ {
147+ case "tree":
148+ dirs = append(dirs, p)
149+ case "blob":
150+ files = append(files, p)
151+ }
152+ }
153+ q.Dirs = append([]string{""}, spread(dirs, b.Dirs)...)
154+ q.Dirs = append(q.Dirs, "nope", "nope/deeper", "*")
155+ if len(files) > 0 {
156+ q.Dirs = append(q.Dirs, files[0]) // a file where a directory is wanted
157+ }
158+ q.Files = append(spread(files, b.Files), "", "nope", "nope/x")
159+ if len(dirs) > 0 {
160+ q.Files = append(q.Files, dirs[0]) // a directory where a file is wanted
161+ }
162+ q.LogPaths = append([]string{""}, spread(files, b.LogPaths/2+1)...)
163+ q.LogPaths = append(q.LogPaths, spread(dirs, b.LogPaths/2+1)...)
164+ q.LogPaths = append(q.LogPaths, "nope", "*")
165+ q.LogPaths = dedupe(q.LogPaths)
166+ }
167+ for _, r := range q.Revs {
168+ if r != "" {
169+ q.RefPaths = append(q.RefPaths, r, r+"/dir/file", r+"/x")
170+ }
171+ }
172+ q.RefPaths = append(q.RefPaths, "", "/", "nope", "nope/x", "a//b", "../x", "main/", "HEAD/x", "refs/heads/main/x")
173+ q.RefPaths = dedupe(spread(q.RefPaths, 4*b.Revs+12))
174+ return q
175+}
176+
177+func dedupe(s []string) []string {
178+ seen := make(map[string]bool, len(s))
179+ out := s[:0:0]
180+ for _, v := range s {
181+ if !seen[v] {
182+ seen[v] = true
183+ out = append(out, v)
184+ }
185+ }
186+ return out
187+}
188+
189+// Compare asks both readers the same questions about the repository and
190+// requires the same answers: the same values, and the same kind of error. want
191+// is the oracle (the CLI reader).
192+func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir string, b Budget) {
193+ t.Helper()
194+ ctx := context.Background()
195+ q := Derive(t, dir, b)
196+ t.Logf("%d revisions, %d dirs, %d files, %d log paths", len(q.Revs), len(q.Dirs), len(q.Files), len(q.LogPaths))
197+
198+ t.Run("ListRefs", func(t *testing.T) {
199+ w, werr := want.ListRefs(ctx, repo)
200+ g, gerr := got.ListRefs(ctx, repo)
201+ require.Equal(t, errClass(werr), errClass(gerr))
202+ assert.Equal(t, w, g)
203+ })
204+ t.Run("ResolveRevision", func(t *testing.T) {
205+ for _, s := range q.Revs {
206+ rev, err := domain.ParseRevision(s)
207+ if err != nil {
208+ continue
209+ }
210+ w, werr := want.ResolveRevision(ctx, repo, rev)
211+ g, gerr := got.ResolveRevision(ctx, repo, rev)
212+ if assert.Equal(t, errClass(werr), errClass(gerr), "revision %q", s) {
213+ assert.Equal(t, w, g, "revision %q", s)
214+ }
215+ }
216+ })
217+ t.Run("ResolveRefPath", func(t *testing.T) {
218+ for _, p := range q.RefPaths {
219+ w, werr := want.ResolveRefPath(ctx, repo, p)
220+ g, gerr := got.ResolveRefPath(ctx, repo, p)
221+ if assert.Equal(t, errClass(werr), errClass(gerr), "path %q", p) {
222+ assert.Equal(t, w, g, "path %q", p)
223+ }
224+ }
225+ })
226+ revs := make([]domain.Revision, 0, len(q.Revs))
227+ for _, s := range spread(q.Revs, b.Revs+8) {
228+ if rev, err := domain.ParseRevision(s); err == nil {
229+ revs = append(revs, rev)
230+ }
231+ }
232+ t.Run("GetTree", func(t *testing.T) {
233+ last := 0
234+ for _, rev := range revs {
235+ for _, p := range q.Dirs {
236+ path, err := domain.ParseTreePath(p)
237+ if err != nil {
238+ continue
239+ }
240+ w, werr := want.GetTree(ctx, repo, rev, path, false)
241+ g, gerr := got.GetTree(ctx, repo, rev, path, false)
242+ if assert.Equal(t, errClass(werr), errClass(gerr), "tree %q at %q", p, rev) {
243+ assert.Equal(t, w, g, "tree %q at %q", p, rev)
244+ }
245+ // Last commits for the first few revisions only: each is a history walk.
246+ if werr == nil && last < b.LastTrees*3 && len(w.Entries) > 0 {
247+ last++
248+ w, werr := want.GetTree(ctx, repo, rev, path, true)
249+ g, gerr := got.GetTree(ctx, repo, rev, path, true)
250+ if assert.Equal(t, errClass(werr), errClass(gerr), "tree+last %q at %q", p, rev) {
251+ assert.Equal(t, w, g, "tree+last %q at %q", p, rev)
252+ }
253+ }
254+ }
255+ }
256+ })
257+ t.Run("OpenBlob", func(t *testing.T) {
258+ read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
259+ info, rc, err := r.OpenBlob(ctx, repo, rev, path)
260+ if err != nil {
261+ require.Nil(t, rc)
262+ return info, nil, err
263+ }
264+ body, rerr := io.ReadAll(rc)
265+ require.NoError(t, rerr)
266+ require.NoError(t, rc.Close())
267+ return info, body, nil
268+ }
269+ for _, rev := range spread(revs, b.Revs/2+4) {
270+ for _, p := range q.Files {
271+ path, err := domain.ParseTreePath(p)
272+ if err != nil {
273+ continue
274+ }
275+ wi, wb, werr := read(want, rev, path)
276+ gi, gb, gerr := read(got, rev, path)
277+ if assert.Equal(t, errClass(werr), errClass(gerr), "blob %q at %q", p, rev) && werr == nil {
278+ assert.Equal(t, wi, gi, "blob %q at %q", p, rev)
279+ assert.True(t, bytes.Equal(wb, gb), "content of %q at %q differs", p, rev)
280+ }
281+ }
282+ }
283+ })
284+ t.Run("ListCommits", func(t *testing.T) {
285+ pages := [...][2]int{{0, 1000}, {0, 3}, {2, 3}, {5, 2}, {40, 7}, {5000, 20}}
286+ ids := func(p ports.CommitPage) string {
287+ var sb strings.Builder
288+ for _, c := range p.Commits {
289+ sb.WriteString(c.ID.String()[:8] + " ")
290+ }
291+ return sb.String()
292+ }
293+ for _, s := range q.LogRevs {
294+ rev, err := domain.ParseRevision(s)
295+ if err != nil {
296+ continue
297+ }
298+ for _, p := range q.LogPaths {
299+ path, err := domain.ParseTreePath(p)
300+ if err != nil {
301+ continue
302+ }
303+ for _, pg := range pages {
304+ cq := ports.CommitQuery{Revision: rev, Path: path, Skip: pg[0], Limit: pg[1]}
305+ w, werr := want.ListCommits(ctx, repo, cq)
306+ g, gerr := got.ListCommits(ctx, repo, cq)
307+ if assert.Equal(t, errClass(werr), errClass(gerr), "log %q -- %q %v", s, p, pg) {
308+ if !assert.Equal(t, w, g, "log %q -- %q %v\nwant %s\n got %s", s, p, pg, ids(w), ids(g)) {
309+ return
310+ }
311+ }
312+ }
313+ }
314+ }
315+ for _, cq := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
316+ _, werr := want.ListCommits(ctx, repo, cq)
317+ _, gerr := got.ListCommits(ctx, repo, cq)
318+ assert.Equal(t, errClass(werr), errClass(gerr), "%+v", cq)
319+ }
320+ })
321+ t.Run("missing repository", func(t *testing.T) {
322+ nope := RepoName(t, "no-such-repo")
323+ for name, call := range map[string]func(ports.RepoReader) error{
324+ "ListRefs": func(r ports.RepoReader) error { _, err := r.ListRefs(ctx, nope); return err },
325+ "ResolveRevision": func(r ports.RepoReader) error { _, err := r.ResolveRevision(ctx, nope, domain.Revision{}); return err },
326+ "ResolveRefPath": func(r ports.RepoReader) error { _, err := r.ResolveRefPath(ctx, nope, "main"); return err },
327+ "GetTree": func(r ports.RepoReader) error {
328+ _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
329+ return err
330+ },
331+ "OpenBlob": func(r ports.RepoReader) error {
332+ _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
333+ return err
334+ },
335+ "ListCommits": func(r ports.RepoReader) error {
336+ _, err := r.ListCommits(ctx, nope, ports.CommitQuery{Limit: 1})
337+ return err
338+ },
339+ } {
340+ assert.Equal(t, errClass(call(want)), errClass(call(got)), name)
341+ }
342+ })
343+}
R internal/adapters/gitcli/read_test.go → internal/readertest/conformance.go +224 -238 renamed, 55% similar
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package gitcli_test3+package readertest
4 4
5 import (5 import (
6 "context"6 "context"
@@ -18,74 +18,61 @@ import (
18 "bnema.dev/ermite/internal/ports"18 "bnema.dev/ermite/internal/ports"
19 )19 )
20 20
21-func rev(t *testing.T, s string) domain.Revision {21+// Maker builds a RepoReader serving the repositories below data/repos, and
22+// returns it with the data directory. The reader is closed when the test ends.
23+type Maker func(t *testing.T) (ports.RepoReader, string)
24+
25+// Conformance runs the RepoReader contract against the readers mk builds:
26+// every adapter that serves repositories must pass it, whatever its route.
27+func Conformance(t *testing.T, mk Maker) {
28+ for _, tc := range []struct {
29+ name string
30+ fn func(*testing.T, Maker)
31+ }{
32+ {"ListRefs", testListRefs},
33+ {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
34+ {"DetachedHead", testDetachedHead},
35+ {"ResolveRevision", testResolveRevision},
36+ {"ResolveRefPath", testResolveRefPath},
37+ {"GetTree", testGetTree},
38+ {"GetTreeLastCommit", testGetTreeLastCommit},
39+ {"GetTreeLastCommitBound", testGetTreeLastCommitBound},
40+ {"ListCommits", testListCommits},
41+ {"OpenBlob", testOpenBlob},
42+ {"OpenBlobMediaTypes", testOpenBlobMediaTypes},
43+ {"BinaryRuleLooksAtFirst8000Bytes", testBinaryRuleLooksAtFirst8000Bytes},
44+ {"OpenBlobLargeThroughStream", testOpenBlobLargeThroughStream},
45+ {"CancelledContext", testCancelledContext},
46+ {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
47+ {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
48+ {"GetTreeIsBounded", testGetTreeIsBounded},
49+ } {
50+ t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
51+ }
52+}
53+
54+// Rev parses a revision.
55+func Rev(t *testing.T, s string) domain.Revision {
22 t.Helper()56 t.Helper()
23 r, err := domain.ParseRevision(s)57 r, err := domain.ParseRevision(s)
24 require.NoError(t, err)58 require.NoError(t, err)
25 return r59 return r
26 }60 }
27 61
28-func tpath(t *testing.T, s string) domain.TreePath {62+// TreePath parses a tree path.
63+func TreePath(t *testing.T, s string) domain.TreePath {
29 t.Helper()64 t.Helper()
30 p, err := domain.ParseTreePath(s)65 p, err := domain.ParseTreePath(s)
31 require.NoError(t, err)66 require.NoError(t, err)
32 return p67 return p
33 }68 }
34 69
35-// history is the repository of the read tests: three commits on main, a70+func testListRefs(t *testing.T, mk Maker) {
36-// branch with a slash, a merge, lightweight and annotated tags.
37-type history struct {
38- *fixtureRepo
39- c1, c2, c3, side, merge string
40-}
41-
42-func newHistory(t *testing.T, data string) *history {
43- t.Helper()
44- f := newFixtureRepo(t, data, "hist")
45- h := &history{fixtureRepo: f}
46- f.write("a.txt", "one\ntwo\nthree\n").
47- writeBytes("bin", []byte{0, 1}).
48- write("d/f", "hi\n").
49- write("d/sub/g", "deep\n").
50- write("we:ird*.txt", "x").
51- write("README.md", "# hello\n").
52- write("empty", "")
53- h.c1 = f.commit("first\n\nbody of the first commit\n")
54-
55- f.mv("a.txt", "b.txt").write("b.txt", "one\ntwo\nthree\nfour\n").
56- writeBytes("bin", []byte{0, 2}).
57- chmod("d/f", 0o755).
58- write("n.txt", "new\nnonl").
59- remove("README.md")
60- h.c2 = f.commit("second")
61-
62- f.remove("n.txt").symlink("b.txt", "n.txt")
63- h.c3 = f.commit("third: type change")
64-
65- f.checkoutNew("feature/x")
66- f.write("side.txt", "s\n")
67- h.side = f.commit("side work")
68- f.checkout("main")
69- f.write("main.txt", "m\n")
70- f.commit("main work")
71- h.merge = f.merge("feature/x")
72-
73- f.git("tag", "v1", h.c1)
74- f.git("tag", "-a", "-m", "release two", "v2", h.c2)
75- f.git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2")
76- f.git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.merge+"^{tree}")
77- f.git("branch", "release/1.0", h.c2)
78- f.git("branch", "rel/1", h.c1)
79- f.git("tag", "rel/1/x", h.c3)
80- return h
81-}
82-
83-func TestListRefs(t *testing.T) {
84 ctx := context.Background()71 ctx := context.Background()
85- g, data := newReader(t, nil)72+ g, data := mk(t)
86- h := newHistory(t, data)73+ h := NewHistory(t, data)
87 74
88- refs, err := g.ListRefs(ctx, h.name)75+ refs, err := g.ListRefs(ctx, h.Name)
89 require.NoError(t, err)76 require.NoError(t, err)
90 77
91 type row struct {78 type row struct {
@@ -99,27 +86,27 @@ func TestListRefs(t *testing.T) {
99 for _, r := range refs {86 for _, r := range refs {
100 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})87 got = append(got, row{r.Name, r.ShortName, r.Kind, r.ID.String(), r.CommitID.String()})
101 }88 }
102- tagID := func(n string) string { return h.git("rev-parse", "refs/tags/"+n) }89+ tagID := func(n string) string { return h.Git("rev-parse", "refs/tags/"+n) }
103 assert.Equal(t, []row{90 assert.Equal(t, []row{
104- {"HEAD", "main", domain.RefKindHead, h.merge, h.merge},91+ {"HEAD", "main", domain.RefKindHead, h.Merge, h.Merge},
105- {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.side, h.side},92+ {"refs/heads/feature/x", "feature/x", domain.RefKindBranch, h.Side, h.Side},
106- {"refs/heads/main", "main", domain.RefKindBranch, h.merge, h.merge},93+ {"refs/heads/main", "main", domain.RefKindBranch, h.Merge, h.Merge},
107- {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.c1, h.c1},94+ {"refs/heads/rel/1", "rel/1", domain.RefKindBranch, h.C1, h.C1},
108- {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.c2, h.c2},95+ {"refs/heads/release/1.0", "release/1.0", domain.RefKindBranch, h.C2, h.C2},
109- {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.c3, h.c3},96+ {"refs/tags/rel/1/x", "rel/1/x", domain.RefKindTag, h.C3, h.C3},
110 {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},97 {"refs/tags/tree-tag", "tree-tag", domain.RefKindTag, tagID("tree-tag"), ""},
111- {"refs/tags/v1", "v1", domain.RefKindTag, h.c1, h.c1},98+ {"refs/tags/v1", "v1", domain.RefKindTag, h.C1, h.C1},
112- {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.c2},99+ {"refs/tags/v2", "v2", domain.RefKindTag, tagID("v2"), h.C2},
113- {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.c2},100+ {"refs/tags/v2-again", "v2-again", domain.RefKindTag, tagID("v2-again"), h.C2},
114 }, got)101 }, got)
115 }102 }
116 103
117-func TestListRefsEmptyAndMissing(t *testing.T) {104+func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
118 ctx := context.Background()105 ctx := context.Background()
119- g, data := newReader(t, nil)106+ g, data := mk(t)
120- f := newFixtureRepo(t, data, "empty")107+ f := NewFixture(t, data, "empty")
121 108
122- refs, err := g.ListRefs(ctx, f.name)109+ refs, err := g.ListRefs(ctx, f.Name)
123 require.NoError(t, err)110 require.NoError(t, err)
124 require.Len(t, refs, 1)111 require.Len(t, refs, 1)
125 assert.Equal(t, "HEAD", refs[0].Name)112 assert.Equal(t, "HEAD", refs[0].Name)
@@ -127,85 +114,85 @@ func TestListRefsEmptyAndMissing(t *testing.T) {
127 assert.True(t, refs[0].ID.IsZero())114 assert.True(t, refs[0].ID.IsZero())
128 assert.True(t, refs[0].CommitID.IsZero())115 assert.True(t, refs[0].CommitID.IsZero())
129 116
130- _, err = g.ListRefs(ctx, name(t, "nope"))117+ _, err = g.ListRefs(ctx, RepoName(t, "nope"))
131 assert.ErrorIs(t, err, domain.ErrNotFound)118 assert.ErrorIs(t, err, domain.ErrNotFound)
132 119
133 // No commit to resolve in an empty repository.120 // No commit to resolve in an empty repository.
134- _, err = g.ResolveRevision(ctx, f.name, domain.Revision{})121+ _, err = g.ResolveRevision(ctx, f.Name, domain.Revision{})
135 assert.ErrorIs(t, err, domain.ErrNotFound)122 assert.ErrorIs(t, err, domain.ErrNotFound)
136- _, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)123+ _, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
137 assert.ErrorIs(t, err, domain.ErrNotFound)124 assert.ErrorIs(t, err, domain.ErrNotFound)
138- _, err = g.ListCommits(ctx, f.name, ports.CommitQuery{Limit: 10})125+ _, err = g.ListCommits(ctx, f.Name, ports.CommitQuery{Limit: 10})
139 assert.ErrorIs(t, err, domain.ErrNotFound)126 assert.ErrorIs(t, err, domain.ErrNotFound)
140 }127 }
141 128
142-func TestDetachedHead(t *testing.T) {129+func testDetachedHead(t *testing.T, mk Maker) {
143 ctx := context.Background()130 ctx := context.Background()
144- g, data := newReader(t, nil)131+ g, data := mk(t)
145- h := newHistory(t, data)132+ h := NewHistory(t, data)
146- h.git("update-ref", "--no-deref", "HEAD", h.c1)133+ h.Git("update-ref", "--no-deref", "HEAD", h.C1)
147 134
148- refs, err := g.ListRefs(ctx, h.name)135+ refs, err := g.ListRefs(ctx, h.Name)
149 require.NoError(t, err)136 require.NoError(t, err)
150 assert.Equal(t, "HEAD", refs[0].Name)137 assert.Equal(t, "HEAD", refs[0].Name)
151 assert.Empty(t, refs[0].ShortName)138 assert.Empty(t, refs[0].ShortName)
152 }139 }
153 140
154-func TestResolveRevision(t *testing.T) {141+func testResolveRevision(t *testing.T, mk Maker) {
155 ctx := context.Background()142 ctx := context.Background()
156- g, data := newReader(t, nil)143+ g, data := mk(t)
157- h := newHistory(t, data)144+ h := NewHistory(t, data)
158 145
159 tests := []struct {146 tests := []struct {
160 rev string147 rev string
161 commit string148 commit string
162 ref string149 ref string
163 }{150 }{
164- {"", h.merge, ""},151+ {"", h.Merge, ""},
165- {"HEAD", h.merge, ""},152+ {"HEAD", h.Merge, ""},
166- {"main", h.merge, "refs/heads/main"},153+ {"main", h.Merge, "refs/heads/main"},
167- {"feature/x", h.side, "refs/heads/feature/x"},154+ {"feature/x", h.Side, "refs/heads/feature/x"},
168- {"refs/heads/feature/x", h.side, "refs/heads/feature/x"},155+ {"refs/heads/feature/x", h.Side, "refs/heads/feature/x"},
169- {"v1", h.c1, "refs/tags/v1"},156+ {"v1", h.C1, "refs/tags/v1"},
170- {"v2", h.c2, "refs/tags/v2"},157+ {"v2", h.C2, "refs/tags/v2"},
171- {"v2-again", h.c2, "refs/tags/v2-again"},158+ {"v2-again", h.C2, "refs/tags/v2-again"},
172- {"refs/tags/v2", h.c2, "refs/tags/v2"},159+ {"refs/tags/v2", h.C2, "refs/tags/v2"},
173- {h.c2, h.c2, ""},160+ {h.C2, h.C2, ""},
174- {h.c2[:7], h.c2, ""},161+ {h.C2[:7], h.C2, ""},
175- {h.c2[:12], h.c2, ""},162+ {h.C2[:12], h.C2, ""},
176 }163 }
177 for _, tt := range tests {164 for _, tt := range tests {
178 t.Run(tt.rev, func(t *testing.T) {165 t.Run(tt.rev, func(t *testing.T) {
179- got, err := g.ResolveRevision(ctx, h.name, rev(t, tt.rev))166+ got, err := g.ResolveRevision(ctx, h.Name, Rev(t, tt.rev))
180 require.NoError(t, err)167 require.NoError(t, err)
181 assert.Equal(t, tt.commit, got.CommitID.String())168 assert.Equal(t, tt.commit, got.CommitID.String())
182 assert.Equal(t, tt.ref, got.Ref)169 assert.Equal(t, tt.ref, got.Ref)
183 })170 })
184 }171 }
185- for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.c2[:6], "refs/heads", h.git("rev-parse", h.merge+"^{tree}")} {172+ for _, bad := range []string{"nope", "tree-tag", "deadbeefdeadbeef", "HEAD~1", h.C2[:6], "refs/heads", h.Git("rev-parse", h.Merge+"^{tree}")} {
186 t.Run("missing "+bad, func(t *testing.T) {173 t.Run("missing "+bad, func(t *testing.T) {
187 r, err := domain.ParseRevision(bad)174 r, err := domain.ParseRevision(bad)
188 if err != nil {175 if err != nil {
189 return // not a revision at all, rejected before the reader176 return // not a revision at all, rejected before the reader
190 }177 }
191- _, err = g.ResolveRevision(ctx, h.name, r)178+ _, err = g.ResolveRevision(ctx, h.Name, r)
192 assert.ErrorIs(t, err, domain.ErrNotFound)179 assert.ErrorIs(t, err, domain.ErrNotFound)
193 })180 })
194 }181 }
195 182
196 t.Run("a branch wins over a tag of the same name", func(t *testing.T) {183 t.Run("a branch wins over a tag of the same name", func(t *testing.T) {
197- h.git("branch", "both", h.c1)184+ h.Git("branch", "both", h.C1)
198- h.git("tag", "both", h.c2)185+ h.Git("tag", "both", h.C2)
199- got, err := g.ResolveRevision(ctx, h.name, rev(t, "both"))186+ got, err := g.ResolveRevision(ctx, h.Name, Rev(t, "both"))
200 require.NoError(t, err)187 require.NoError(t, err)
201- assert.Equal(t, h.c1, got.CommitID.String())188+ assert.Equal(t, h.C1, got.CommitID.String())
202 })189 })
203 }190 }
204 191
205-func TestResolveRefPath(t *testing.T) {192+func testResolveRefPath(t *testing.T, mk Maker) {
206 ctx := context.Background()193 ctx := context.Background()
207- g, data := newReader(t, nil)194+ g, data := mk(t)
208- h := newHistory(t, data)195+ h := NewHistory(t, data)
209 196
210 tests := []struct {197 tests := []struct {
211 path string198 path string
@@ -214,22 +201,22 @@ func TestResolveRefPath(t *testing.T) {
214 id string201 id string
215 rest string202 rest string
216 }{203 }{
217- {"main", "main", "refs/heads/main", h.merge, ""},204+ {"main", "main", "refs/heads/main", h.Merge, ""},
218- {"main/d/f", "main", "refs/heads/main", h.merge, "d/f"},205+ {"main/d/f", "main", "refs/heads/main", h.Merge, "d/f"},
219- {"feature/x", "feature/x", "refs/heads/feature/x", h.side, ""},206+ {"feature/x", "feature/x", "refs/heads/feature/x", h.Side, ""},
220- {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.side, "d/f"},207+ {"feature/x/d/f", "feature/x", "refs/heads/feature/x", h.Side, "d/f"},
221- {"feature/x/", "feature/x", "refs/heads/feature/x", h.side, ""},208+ {"feature/x/", "feature/x", "refs/heads/feature/x", h.Side, ""},
222- {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.c2, "a.txt"},209+ {"release/1.0/a.txt", "release/1.0", "refs/heads/release/1.0", h.C2, "a.txt"},
223 // The longest ref wins: the tag rel/1/x over the branch rel/1.210 // The longest ref wins: the tag rel/1/x over the branch rel/1.
224- {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.c3, "d/f"},211+ {"rel/1/x/d/f", "rel/1/x", "refs/tags/rel/1/x", h.C3, "d/f"},
225- {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.c1, "d/f"},212+ {"rel/1/d/f", "rel/1", "refs/heads/rel/1", h.C1, "d/f"},
226- {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.c2, "we:ird*.txt"},213+ {"v2/we:ird*.txt", "v2", "refs/tags/v2", h.C2, "we:ird*.txt"},
227- {h.c1 + "/d", h.c1, "", h.c1, "d"},214+ {h.C1 + "/d", h.C1, "", h.C1, "d"},
228- {h.c1[:8] + "/d/f", h.c1[:8], "", h.c1, "d/f"},215+ {h.C1[:8] + "/d/f", h.C1[:8], "", h.C1, "d/f"},
229 }216 }
230 for _, tt := range tests {217 for _, tt := range tests {
231 t.Run(tt.path, func(t *testing.T) {218 t.Run(tt.path, func(t *testing.T) {
232- got, err := g.ResolveRefPath(ctx, h.name, tt.path)219+ got, err := g.ResolveRefPath(ctx, h.Name, tt.path)
233 require.NoError(t, err)220 require.NoError(t, err)
234 assert.Equal(t, tt.name, got.Name)221 assert.Equal(t, tt.name, got.Name)
235 assert.Equal(t, tt.ref, got.Ref)222 assert.Equal(t, tt.ref, got.Ref)
@@ -239,24 +226,24 @@ func TestResolveRefPath(t *testing.T) {
239 }226 }
240 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {227 for _, bad := range []string{"", "/", "nope", "nope/d/f", "a//b", "../x", "d/f"} {
241 t.Run("missing "+bad, func(t *testing.T) {228 t.Run("missing "+bad, func(t *testing.T) {
242- _, err := g.ResolveRefPath(ctx, h.name, bad)229+ _, err := g.ResolveRefPath(ctx, h.Name, bad)
243 assert.ErrorIs(t, err, domain.ErrNotFound)230 assert.ErrorIs(t, err, domain.ErrNotFound)
244 })231 })
245 }232 }
246- _, err := g.ResolveRefPath(ctx, name(t, "nope"), "main")233+ _, err := g.ResolveRefPath(ctx, RepoName(t, "nope"), "main")
247 assert.ErrorIs(t, err, domain.ErrNotFound)234 assert.ErrorIs(t, err, domain.ErrNotFound)
248 }235 }
249 236
250-func TestGetTree(t *testing.T) {237+func testGetTree(t *testing.T, mk Maker) {
251 ctx := context.Background()238 ctx := context.Background()
252- g, data := newReader(t, nil)239+ g, data := mk(t)
253- h := newHistory(t, data)240+ h := NewHistory(t, data)
254 241
255 t.Run("root at HEAD", func(t *testing.T) {242 t.Run("root at HEAD", func(t *testing.T) {
256- tree, err := g.GetTree(ctx, h.name, domain.Revision{}, domain.TreePath{}, false)243+ tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, domain.TreePath{}, false)
257 require.NoError(t, err)244 require.NoError(t, err)
258- assert.Equal(t, h.merge, tree.CommitID.String())245+ assert.Equal(t, h.Merge, tree.CommitID.String())
259- assert.Equal(t, h.git("rev-parse", h.merge+"^{tree}"), tree.ID.String())246+ assert.Equal(t, h.Git("rev-parse", h.Merge+"^{tree}"), tree.ID.String())
260 type row struct {247 type row struct {
261 name, path string248 name, path string
262 kind domain.EntryKind249 kind domain.EntryKind
@@ -282,7 +269,7 @@ func TestGetTree(t *testing.T) {
282 })269 })
283 270
284 t.Run("subdirectory with an executable", func(t *testing.T) {271 t.Run("subdirectory with an executable", func(t *testing.T) {
285- tree, err := g.GetTree(ctx, h.name, rev(t, "v2"), tpath(t, "d"), false)272+ tree, err := g.GetTree(ctx, h.Name, Rev(t, "v2"), TreePath(t, "d"), false)
286 require.NoError(t, err)273 require.NoError(t, err)
287 require.Len(t, tree.Entries, 2)274 require.Len(t, tree.Entries, 2)
288 assert.Equal(t, "sub", tree.Entries[0].Name)275 assert.Equal(t, "sub", tree.Entries[0].Name)
@@ -290,42 +277,42 @@ func TestGetTree(t *testing.T) {
290 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)277 assert.Equal(t, domain.EntryExecutable, tree.Entries[1].Kind)
291 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)278 assert.Equal(t, uint32(0o100755), tree.Entries[1].Mode)
292 assert.Equal(t, "d/f", tree.Entries[1].Path)279 assert.Equal(t, "d/f", tree.Entries[1].Path)
293- assert.Equal(t, h.c2, tree.CommitID.String())280+ assert.Equal(t, h.C2, tree.CommitID.String())
294 })281 })
295 282
296 t.Run("errors", func(t *testing.T) {283 t.Run("errors", func(t *testing.T) {
297- _, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "nope"), false)284+ _, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "nope"), false)
298 assert.ErrorIs(t, err, domain.ErrNotFound)285 assert.ErrorIs(t, err, domain.ErrNotFound)
299- _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d/nope/x"), false)286+ _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d/nope/x"), false)
300 assert.ErrorIs(t, err, domain.ErrNotFound)287 assert.ErrorIs(t, err, domain.ErrNotFound)
301- _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "b.txt"), false)288+ _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "b.txt"), false)
302 assert.ErrorIs(t, err, domain.ErrNotDirectory)289 assert.ErrorIs(t, err, domain.ErrNotDirectory)
303- _, err = g.GetTree(ctx, h.name, rev(t, "nope"), domain.TreePath{}, false)290+ _, err = g.GetTree(ctx, h.Name, Rev(t, "nope"), domain.TreePath{}, false)
304 assert.ErrorIs(t, err, domain.ErrNotFound)291 assert.ErrorIs(t, err, domain.ErrNotFound)
305 })292 })
306 293
307 t.Run("paths are literal", func(t *testing.T) {294 t.Run("paths are literal", func(t *testing.T) {
308- tree, err := g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "d"), true)295+ tree, err := g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "d"), true)
309 require.NoError(t, err)296 require.NoError(t, err)
310 require.Len(t, tree.Entries, 2)297 require.Len(t, tree.Entries, 2)
311- _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, "*"), false)298+ _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, "*"), false)
312 assert.ErrorIs(t, err, domain.ErrNotFound)299 assert.ErrorIs(t, err, domain.ErrNotFound)
313 })300 })
314 }301 }
315 302
316-func TestGetTreeLastCommit(t *testing.T) {303+func testGetTreeLastCommit(t *testing.T, mk Maker) {
317 ctx := context.Background()304 ctx := context.Background()
318- g, data := newReader(t, nil)305+ g, data := mk(t)
319- h := newHistory(t, data)306+ h := NewHistory(t, data)
320 307
321- tree, err := g.GetTree(ctx, h.name, rev(t, "main"), domain.TreePath{}, true)308+ tree, err := g.GetTree(ctx, h.Name, Rev(t, "main"), domain.TreePath{}, true)
322 require.NoError(t, err)309 require.NoError(t, err)
323 got := map[string]string{}310 got := map[string]string{}
324 for _, e := range tree.Entries {311 for _, e := range tree.Entries {
325 require.NotNil(t, e.LastCommit, e.Path)312 require.NotNil(t, e.LastCommit, e.Path)
326 got[e.Path] = e.LastCommit.Summary313 got[e.Path] = e.LastCommit.Summary
327 // The oracle: git log -1 for the same path.314 // The oracle: git log -1 for the same path.
328- assert.Equal(t, h.git("log", "-1", "--format=%H", h.merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)315+ assert.Equal(t, h.Git("log", "-1", "--format=%H", h.Merge, "--", e.Path), e.LastCommit.ID.String(), e.Path)
329 }316 }
330 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")317 assert.Equal(t, "second", got["d"], "chmod of d/f is the last change of d")
331 assert.Equal(t, "second", got["b.txt"])318 assert.Equal(t, "second", got["b.txt"])
@@ -335,17 +322,17 @@ func TestGetTreeLastCommit(t *testing.T) {
335 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)322 assert.Equal(t, "Ada Lovelace", tree.Entries[0].LastCommit.Author.Name)
336 }323 }
337 324
338-func TestGetTreeLastCommitBound(t *testing.T) {325+func testGetTreeLastCommitBound(t *testing.T, mk Maker) {
339 ctx := context.Background()326 ctx := context.Background()
340- g, data := newReader(t, nil)327+ g, data := mk(t)
341- f := newFixtureRepo(t, data, "wide")328+ f := NewFixture(t, data, "wide")
342 n := domain.MaxLastCommitEntries + 5329 n := domain.MaxLastCommitEntries + 5
343 for i := range n {330 for i := range n {
344- f.write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")331+ f.Write("f"+strings.Repeat("0", 3-len(itoa(i)))+itoa(i), "x\n")
345 }332 }
346- f.commit("wide")333+ f.Commit("wide")
347 334
348- tree, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, true)335+ tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, true)
349 require.NoError(t, err)336 require.NoError(t, err)
350 require.Len(t, tree.Entries, n)337 require.Len(t, tree.Entries, n)
351 assert.True(t, tree.LastCommitsOmitted)338 assert.True(t, tree.LastCommitsOmitted)
@@ -359,7 +346,7 @@ func TestGetTreeLastCommitBound(t *testing.T) {
359 assert.NotNil(t, tree.Entries[0].LastCommit)346 assert.NotNil(t, tree.Entries[0].LastCommit)
360 assert.Nil(t, tree.Entries[n-1].LastCommit)347 assert.Nil(t, tree.Entries[n-1].LastCommit)
361 348
362- tree, err = g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)349+ tree, err = g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
363 require.NoError(t, err)350 require.NoError(t, err)
364 assert.False(t, tree.LastCommitsOmitted)351 assert.False(t, tree.LastCommitsOmitted)
365 }352 }
@@ -375,18 +362,18 @@ func itoa(i int) string {
375 return string(b)362 return string(b)
376 }363 }
377 364
378-func TestListCommits(t *testing.T) {365+func testListCommits(t *testing.T, mk Maker) {
379 ctx := context.Background()366 ctx := context.Background()
380- g, data := newReader(t, nil)367+ g, data := mk(t)
381- h := newHistory(t, data)368+ h := NewHistory(t, data)
382 369
383- oracle := strings.Split(h.git("log", "--format=%H", h.merge), "\n")370+ oracle := strings.Split(h.Git("log", "--format=%H", h.Merge), "\n")
384 require.Len(t, oracle, 6)371 require.Len(t, oracle, 6)
385 372
386 t.Run("all", func(t *testing.T) {373 t.Run("all", func(t *testing.T) {
387- page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 100})374+ page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 100})
388 require.NoError(t, err)375 require.NoError(t, err)
389- assert.Equal(t, h.merge, page.Head.String())376+ assert.Equal(t, h.Merge, page.Head.String())
390 assert.False(t, page.HasMore)377 assert.False(t, page.HasMore)
391 var ids []string378 var ids []string
392 for _, c := range page.Commits {379 for _, c := range page.Commits {
@@ -399,7 +386,7 @@ func TestListCommits(t *testing.T) {
399 var ids []string386 var ids []string
400 skip := 0387 skip := 0
401 for {388 for {
402- page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Skip: skip, Limit: 2})389+ page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Skip: skip, Limit: 2})
403 require.NoError(t, err)390 require.NoError(t, err)
404 for _, c := range page.Commits {391 for _, c := range page.Commits {
405 ids = append(ids, c.ID.String())392 ids = append(ids, c.ID.String())
@@ -414,33 +401,33 @@ func TestListCommits(t *testing.T) {
414 })401 })
415 402
416 t.Run("path", func(t *testing.T) {403 t.Run("path", func(t *testing.T) {
417- page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "d/f"), Limit: 10})404+ page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "d/f"), Limit: 10})
418 require.NoError(t, err)405 require.NoError(t, err)
419 var ids []string406 var ids []string
420 for _, c := range page.Commits {407 for _, c := range page.Commits {
421 ids = append(ids, c.ID.String())408 ids = append(ids, c.ID.String())
422 }409 }
423- assert.Equal(t, strings.Split(h.git("log", "--format=%H", h.merge, "--", "d/f"), "\n"), ids)410+ assert.Equal(t, strings.Split(h.Git("log", "--format=%H", h.Merge, "--", "d/f"), "\n"), ids)
424 assert.Len(t, ids, 2)411 assert.Len(t, ids, 2)
425 412
426- page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "we:ird*.txt"), Limit: 10})413+ page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "we:ird*.txt"), Limit: 10})
427 require.NoError(t, err)414 require.NoError(t, err)
428 require.Len(t, page.Commits, 1, "paths are literal")415 require.Len(t, page.Commits, 1, "paths are literal")
429- page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Path: tpath(t, "*"), Limit: 10})416+ page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Path: TreePath(t, "*"), Limit: 10})
430 require.NoError(t, err)417 require.NoError(t, err)
431 assert.Empty(t, page.Commits)418 assert.Empty(t, page.Commits)
432 })419 })
433 420
434 t.Run("revision", func(t *testing.T) {421 t.Run("revision", func(t *testing.T) {
435- page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "v2"), Limit: 10})422+ page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "v2"), Limit: 10})
436 require.NoError(t, err)423 require.NoError(t, err)
437 require.Len(t, page.Commits, 2)424 require.Len(t, page.Commits, 2)
438- assert.Equal(t, h.c2, page.Head.String())425+ assert.Equal(t, h.C2, page.Head.String())
439- assert.Equal(t, h.c2, page.Commits[0].ID.String())426+ assert.Equal(t, h.C2, page.Commits[0].ID.String())
440 })427 })
441 428
442 t.Run("metadata", func(t *testing.T) {429 t.Run("metadata", func(t *testing.T) {
443- page, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: domain.RevisionOf(mustID(t, h.c1)), Limit: 10})430+ page, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: domain.RevisionOf(MustID(t, h.C1)), Limit: 10})
444 require.NoError(t, err)431 require.NoError(t, err)
445 require.Len(t, page.Commits, 1)432 require.Len(t, page.Commits, 1)
446 c := page.Commits[0]433 c := page.Commits[0]
@@ -451,25 +438,26 @@ func TestListCommits(t *testing.T) {
451 assert.Equal(t, "Grace Hopper", c.Committer.Name)438 assert.Equal(t, "Grace Hopper", c.Committer.Name)
452 assert.Equal(t, "grace@example.org", c.Committer.Email)439 assert.Equal(t, "grace@example.org", c.Committer.Email)
453 440
454- page, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 1})441+ page, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 1})
455 require.NoError(t, err)442 require.NoError(t, err)
456 require.Len(t, page.Commits, 1)443 require.Len(t, page.Commits, 1)
457- assert.Equal(t, []string{h.git("rev-parse", h.merge+"^1"), h.side}, idStrings(page.Commits[0].Parents))444+ assert.Equal(t, []string{h.Git("rev-parse", h.Merge+"^1"), h.Side}, idStrings(page.Commits[0].Parents))
458 })445 })
459 446
460 t.Run("bad queries", func(t *testing.T) {447 t.Run("bad queries", func(t *testing.T) {
461 for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {448 for _, q := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
462- _, err := g.ListCommits(ctx, h.name, q)449+ _, err := g.ListCommits(ctx, h.Name, q)
463 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)450 assert.ErrorIs(t, err, domain.ErrInvalidPageSize, "%+v", q)
464 }451 }
465- _, err := g.ListCommits(ctx, h.name, ports.CommitQuery{Revision: rev(t, "nope"), Limit: 1})452+ _, err := g.ListCommits(ctx, h.Name, ports.CommitQuery{Revision: Rev(t, "nope"), Limit: 1})
466 assert.ErrorIs(t, err, domain.ErrNotFound)453 assert.ErrorIs(t, err, domain.ErrNotFound)
467- _, err = g.ListCommits(ctx, name(t, "nope"), ports.CommitQuery{Limit: 1})454+ _, err = g.ListCommits(ctx, RepoName(t, "nope"), ports.CommitQuery{Limit: 1})
468 assert.ErrorIs(t, err, domain.ErrNotFound)455 assert.ErrorIs(t, err, domain.ErrNotFound)
469 })456 })
470 }457 }
471 458
472-func mustID(t *testing.T, s string) domain.ObjectID {459+// MustID parses an object id.
460+func MustID(t *testing.T, s string) domain.ObjectID {
473 t.Helper()461 t.Helper()
474 id, err := domain.ParseObjectID(s)462 id, err := domain.ParseObjectID(s)
475 require.NoError(t, err)463 require.NoError(t, err)
@@ -484,14 +472,14 @@ func idStrings(ids []domain.ObjectID) []string {
484 return out472 return out
485 }473 }
486 474
487-func TestOpenBlob(t *testing.T) {475+func testOpenBlob(t *testing.T, mk Maker) {
488 ctx := context.Background()476 ctx := context.Background()
489- g, data := newReader(t, nil)477+ g, data := mk(t)
490- h := newHistory(t, data)478+ h := NewHistory(t, data)
491 479
492 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {480 read := func(t *testing.T, r string, p string) (domain.BlobInfo, string) {
493 t.Helper()481 t.Helper()
494- info, rc, err := g.OpenBlob(ctx, h.name, rev(t, r), tpath(t, p))482+ info, rc, err := g.OpenBlob(ctx, h.Name, Rev(t, r), TreePath(t, p))
495 require.NoError(t, err)483 require.NoError(t, err)
496 body, err := io.ReadAll(rc)484 body, err := io.ReadAll(rc)
497 require.NoError(t, err)485 require.NoError(t, err)
@@ -506,7 +494,7 @@ func TestOpenBlob(t *testing.T) {
506 assert.Equal(t, uint32(0o100644), info.Mode)494 assert.Equal(t, uint32(0o100644), info.Mode)
507 assert.False(t, info.Binary)495 assert.False(t, info.Binary)
508 assert.Equal(t, "text/plain", info.MediaType)496 assert.Equal(t, "text/plain", info.MediaType)
509- assert.Equal(t, h.git("rev-parse", "main:b.txt"), info.ID.String())497+ assert.Equal(t, h.Git("rev-parse", "main:b.txt"), info.ID.String())
510 498
511 info, body = read(t, "main", "bin")499 info, body = read(t, "main", "bin")
512 assert.Equal(t, "\x00\x02", body)500 assert.Equal(t, "\x00\x02", body)
@@ -529,30 +517,30 @@ func TestOpenBlob(t *testing.T) {
529 assert.Equal(t, "text/plain", info.MediaType)517 assert.Equal(t, "text/plain", info.MediaType)
530 518
531 for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {519 for p, want := range map[string]error{"d": domain.ErrNotFile, "nope": domain.ErrNotFound, "d/nope": domain.ErrNotFound, "": domain.ErrNotFile} {
532- _, rc, err := g.OpenBlob(ctx, h.name, domain.Revision{}, tpath(t, p))520+ _, rc, err := g.OpenBlob(ctx, h.Name, domain.Revision{}, TreePath(t, p))
533 assert.ErrorIs(t, err, want, p)521 assert.ErrorIs(t, err, want, p)
534 assert.Nil(t, rc)522 assert.Nil(t, rc)
535 }523 }
536- _, _, err := g.OpenBlob(ctx, h.name, rev(t, "nope"), tpath(t, "b.txt"))524+ _, _, err := g.OpenBlob(ctx, h.Name, Rev(t, "nope"), TreePath(t, "b.txt"))
537 assert.ErrorIs(t, err, domain.ErrNotFound)525 assert.ErrorIs(t, err, domain.ErrNotFound)
538 }526 }
539 527
540-func TestOpenBlobMediaTypes(t *testing.T) {528+func testOpenBlobMediaTypes(t *testing.T, mk Maker) {
541 ctx := context.Background()529 ctx := context.Background()
542- g, data := newReader(t, nil)530+ g, data := mk(t)
543- f := newFixtureRepo(t, data, "media")531+ f := NewFixture(t, data, "media")
544 png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")532 png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
545- f.writeBytes("logo.png", png).533+ f.WriteBytes("logo.png", png).
546- writeBytes("noext", png).534+ WriteBytes("noext", png).
547- write("page.html", "<!doctype html><html><body>hi</body></html>\n").535+ Write("page.html", "<!doctype html><html><body>hi</body></html>\n").
548- write("style.css", "a { color: red }\n").536+ Write("style.css", "a { color: red }\n").
549- write("data.json", `{"a":1}`+"\n").537+ Write("data.json", `{"a":1}`+"\n").
550- write("notes", "plain words\n").538+ Write("notes", "plain words\n").
551- write("main.go", "package main\n").539+ Write("main.go", "package main\n").
552- writeBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).540+ WriteBytes("blob.dat", []byte{0xff, 0xfe, 0, 1, 2}).
553- writeBytes("fake.txt", []byte{'a', 0, 'b'}).541+ WriteBytes("fake.txt", []byte{'a', 0, 'b'}).
554- write("latin", "caf\xe9\n")542+ Write("latin", "caf\xe9\n")
555- f.commit("media")543+ f.Commit("media")
556 544
557 want := map[string]struct {545 want := map[string]struct {
558 media string546 media string
@@ -570,7 +558,7 @@ func TestOpenBlobMediaTypes(t *testing.T) {
570 "latin": {"text/plain", false},558 "latin": {"text/plain", false},
571 }559 }
572 for p, w := range want {560 for p, w := range want {
573- info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))561+ info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
574 require.NoError(t, err, p)562 require.NoError(t, err, p)
575 require.NoError(t, rc.Close())563 require.NoError(t, rc.Close())
576 assert.Equal(t, w.media, info.MediaType, p)564 assert.Equal(t, w.media, info.MediaType, p)
@@ -578,69 +566,67 @@ func TestOpenBlobMediaTypes(t *testing.T) {
578 }566 }
579 }567 }
580 568
581-func TestBinaryRuleLooksAtFirst8000Bytes(t *testing.T) {569+func testBinaryRuleLooksAtFirst8000Bytes(t *testing.T, mk Maker) {
582 ctx := context.Background()570 ctx := context.Background()
583- g, data := newReader(t, nil)571+ g, data := mk(t)
584- f := newFixtureRepo(t, data, "sniff")572+ f := NewFixture(t, data, "sniff")
585 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen573 late := append([]byte(strings.Repeat("a", 8000)), 0) // NUL at offset 8000: not seen
586 early := append([]byte(strings.Repeat("a", 7999)), 0)574 early := append([]byte(strings.Repeat("a", 7999)), 0)
587- f.writeBytes("late", late).writeBytes("early", early)575+ f.WriteBytes("late", late).WriteBytes("early", early)
588 // Over the in-memory threshold, so that the streaming path decides too.576 // Over the in-memory threshold, so that the streaming path decides too.
589- f.writeBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).577+ f.WriteBytes("biglate", append([]byte(strings.Repeat("a", 300<<10)), 0)).
590- writeBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))578+ WriteBytes("bigearly", append([]byte(strings.Repeat("a", 7999)), append([]byte{0}, make([]byte, 300<<10)...)...))
591- f.commit("sniff")579+ f.Commit("sniff")
592 580
593 for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {581 for p, binary := range map[string]bool{"late": false, "early": true, "biglate": false, "bigearly": true} {
594- info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, p))582+ info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, p))
595 require.NoError(t, err, p)583 require.NoError(t, err, p)
596 require.NoError(t, rc.Close())584 require.NoError(t, rc.Close())
597 assert.Equal(t, binary, info.Binary, p)585 assert.Equal(t, binary, info.Binary, p)
598 }586 }
599 }587 }
600 588
601-func TestOpenBlobLargeThroughStream(t *testing.T) {589+func testOpenBlobLargeThroughStream(t *testing.T, mk Maker) {
602 ctx := context.Background()590 ctx := context.Background()
603- g, data := newReader(t, nil)591+ g, data := mk(t)
604- f := newFixtureRepo(t, data, "large")592+ f := NewFixture(t, data, "large")
605- content := randomBytes(1<<20+17, 7)593+ content := RandomBytes(1<<20+17, 7)
606- f.writeBytes("big.bin", content).commit("big")594+ f.WriteBytes("big.bin", content).Commit("big")
607 595
608- info, rc, err := g.OpenBlob(ctx, f.name, domain.Revision{}, tpath(t, "big.bin"))596+ info, rc, err := g.OpenBlob(ctx, f.Name, domain.Revision{}, TreePath(t, "big.bin"))
609 require.NoError(t, err)597 require.NoError(t, err)
610 assert.Equal(t, int64(len(content)), info.Size)598 assert.Equal(t, int64(len(content)), info.Size)
611- assert.Equal(t, 1, g.BlobStreams())
612 got, err := io.ReadAll(rc)599 got, err := io.ReadAll(rc)
613 require.NoError(t, err)600 require.NoError(t, err)
614 assert.Equal(t, content, got)601 assert.Equal(t, content, got)
615 require.NoError(t, rc.Close())602 require.NoError(t, rc.Close())
616 require.NoError(t, rc.Close(), "Close is idempotent")603 require.NoError(t, rc.Close(), "Close is idempotent")
617- assert.Equal(t, 0, g.BlobStreams())
618 }604 }
619 605
620-func TestCancelledContext(t *testing.T) {606+func testCancelledContext(t *testing.T, mk Maker) {
621- g, data := newReader(t, nil)607+ g, data := mk(t)
622- h := newHistory(t, data)608+ h := NewHistory(t, data)
623 609
624 ctx, cancel := context.WithCancel(context.Background())610 ctx, cancel := context.WithCancel(context.Background())
625 cancel()611 cancel()
626- _, err := g.ListRefs(ctx, h.name)612+ _, err := g.ListRefs(ctx, h.Name)
627 assert.ErrorIs(t, err, context.Canceled)613 assert.ErrorIs(t, err, context.Canceled)
628- _, err = g.ResolveRevision(ctx, h.name, domain.Revision{})614+ _, err = g.ResolveRevision(ctx, h.Name, domain.Revision{})
629 assert.ErrorIs(t, err, context.Canceled)615 assert.ErrorIs(t, err, context.Canceled)
630- _, err = g.GetCommit(ctx, h.name, domain.Revision{}, domain.DefaultDiffLimits())616+ _, err = g.GetCommit(ctx, h.Name, domain.Revision{}, domain.DefaultDiffLimits())
631 assert.ErrorIs(t, err, context.Canceled)617 assert.ErrorIs(t, err, context.Canceled)
632- _, err = g.ListCommits(ctx, h.name, ports.CommitQuery{Limit: 5})618+ _, err = g.ListCommits(ctx, h.Name, ports.CommitQuery{Limit: 5})
633 assert.ErrorIs(t, err, context.Canceled)619 assert.ErrorIs(t, err, context.Canceled)
634 620
635 // The adapter still works with a live context.621 // The adapter still works with a live context.
636- _, err = g.ResolveRevision(context.Background(), h.name, domain.Revision{})622+ _, err = g.ResolveRevision(context.Background(), h.Name, domain.Revision{})
637 assert.NoError(t, err)623 assert.NoError(t, err)
638 }624 }
639 625
640-func TestHostileRevisionAndPathStayLiteral(t *testing.T) {626+func testHostileRevisionAndPathStayLiteral(t *testing.T, mk Maker) {
641 ctx := context.Background()627 ctx := context.Background()
642- g, data := newReader(t, nil)628+ g, data := mk(t)
643- h := newHistory(t, data)629+ h := NewHistory(t, data)
644 630
645 // Revisions that look like options or git revision syntax are rejected by631 // Revisions that look like options or git revision syntax are rejected by
646 // the domain; ones that pass it are plain names for the reader.632 // the domain; ones that pass it are plain names for the reader.
@@ -648,9 +634,9 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
648 _, err := domain.ParseRevision(s)634 _, err := domain.ParseRevision(s)
649 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)635 assert.ErrorIs(t, err, domain.ErrInvalidRevision, s)
650 }636 }
651- _, err := g.ResolveRevision(ctx, h.name, rev(t, "HEAD.x"))637+ _, err := g.ResolveRevision(ctx, h.Name, Rev(t, "HEAD.x"))
652 assert.ErrorIs(t, err, domain.ErrNotFound)638 assert.ErrorIs(t, err, domain.ErrNotFound)
653- _, err = g.GetTree(ctx, h.name, domain.Revision{}, tpath(t, ":(top)d"), false)639+ _, err = g.GetTree(ctx, h.Name, domain.Revision{}, TreePath(t, ":(top)d"), false)
654 assert.ErrorIs(t, err, domain.ErrNotFound)640 assert.ErrorIs(t, err, domain.ErrNotFound)
655 var pe *domain.InvalidNameError641 var pe *domain.InvalidNameError
656 assert.False(t, errors.As(err, &pe))642 assert.False(t, errors.As(err, &pe))
@@ -658,60 +644,60 @@ func TestHostileRevisionAndPathStayLiteral(t *testing.T) {
658 644
659 // A full object id is an object id before it is a ref: a branch or a tag645 // A full object id is an object id before it is a ref: a branch or a tag
660 // named like a commit must not redirect a link to that commit.646 // named like a commit must not redirect a link to that commit.
661-func TestResolveRevisionObjectIDBeatsRefs(t *testing.T) {647+func testResolveRevisionObjectIDBeatsRefs(t *testing.T, mk Maker) {
662 ctx := context.Background()648 ctx := context.Background()
663- g, data := newReader(t, nil)649+ g, data := mk(t)
664- h := newHistory(t, data)650+ h := NewHistory(t, data)
665 651
666 // Git itself warns about these refs and prefers the object id.652 // Git itself warns about these refs and prefers the object id.
667- h.git("-c", "advice.objectNameWarning=false", "branch", h.c1, h.c3)653+ h.Git("-c", "advice.objectNameWarning=false", "branch", h.C1, h.C3)
668- h.git("-c", "advice.objectNameWarning=false", "tag", h.c2, h.c3)654+ h.Git("-c", "advice.objectNameWarning=false", "tag", h.C2, h.C3)
669 655
670- got, err := g.ResolveRevision(ctx, h.name, rev(t, h.c1))656+ got, err := g.ResolveRevision(ctx, h.Name, Rev(t, h.C1))
671 require.NoError(t, err)657 require.NoError(t, err)
672- assert.Equal(t, h.c1, got.CommitID.String(), "the commit, not the branch of the same name")658+ assert.Equal(t, h.C1, got.CommitID.String(), "the commit, not the branch of the same name")
673 assert.Empty(t, got.Ref)659 assert.Empty(t, got.Ref)
674 660
675- got, err = g.ResolveRevision(ctx, h.name, rev(t, h.c2))661+ got, err = g.ResolveRevision(ctx, h.Name, Rev(t, h.C2))
676 require.NoError(t, err)662 require.NoError(t, err)
677- assert.Equal(t, h.c2, got.CommitID.String(), "the commit, not the tag of the same name")663+ assert.Equal(t, h.C2, got.CommitID.String(), "the commit, not the tag of the same name")
678 664
679 // An abbreviation is still a name first, as before.665 // An abbreviation is still a name first, as before.
680- h.git("branch", "abc1234", h.c3)666+ h.Git("branch", "abc1234", h.C3)
681- if !strings.HasPrefix(h.c1, "abc1234") {667+ if !strings.HasPrefix(h.C1, "abc1234") {
682- got, err = g.ResolveRevision(ctx, h.name, rev(t, "abc1234"))668+ got, err = g.ResolveRevision(ctx, h.Name, Rev(t, "abc1234"))
683 require.NoError(t, err)669 require.NoError(t, err)
684- assert.Equal(t, h.c3, got.CommitID.String())670+ assert.Equal(t, h.C3, got.CommitID.String())
685 assert.Equal(t, "refs/heads/abc1234", got.Ref)671 assert.Equal(t, "refs/heads/abc1234", got.Ref)
686 }672 }
687 673
688 // The ref is used when no object has that id.674 // The ref is used when no object has that id.
689 ghost := strings.Repeat("ab", 20)675 ghost := strings.Repeat("ab", 20)
690- h.git("-c", "advice.objectNameWarning=false", "branch", ghost, h.c3)676+ h.Git("-c", "advice.objectNameWarning=false", "branch", ghost, h.C3)
691- got, err = g.ResolveRevision(ctx, h.name, rev(t, ghost))677+ got, err = g.ResolveRevision(ctx, h.Name, Rev(t, ghost))
692 require.NoError(t, err)678 require.NoError(t, err)
693- assert.Equal(t, h.c3, got.CommitID.String())679+ assert.Equal(t, h.C3, got.CommitID.String())
694 assert.Equal(t, "refs/heads/"+ghost, got.Ref)680 assert.Equal(t, "refs/heads/"+ghost, got.Ref)
695 }681 }
696 682
697-func TestGetTreeIsBounded(t *testing.T) {683+func testGetTreeIsBounded(t *testing.T, mk Maker) {
698 ctx := context.Background()684 ctx := context.Background()
699- g, data := newReader(t, nil)685+ g, data := mk(t)
700- f := newFixtureRepo(t, data, "wide")686+ f := NewFixture(t, data, "wide")
701 n := domain.MaxTreeEntries + 7687 n := domain.MaxTreeEntries + 7
702 for i := range n {688 for i := range n {
703- f.write(fmt.Sprintf("d/f%05d", i), "x")689+ f.Write(fmt.Sprintf("d/f%05d", i), "x")
704 }690 }
705- f.commit("wide")691+ f.Commit("wide")
706 692
707- tree, err := g.GetTree(ctx, f.name, domain.Revision{}, tpath(t, "d"), false)693+ tree, err := g.GetTree(ctx, f.Name, domain.Revision{}, TreePath(t, "d"), false)
708 require.NoError(t, err)694 require.NoError(t, err)
709 assert.True(t, tree.Truncated)695 assert.True(t, tree.Truncated)
710 require.Len(t, tree.Entries, domain.MaxTreeEntries)696 require.Len(t, tree.Entries, domain.MaxTreeEntries)
711 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")697 assert.Equal(t, "f00000", tree.Entries[0].Name, "the first entries in order")
712 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)698 assert.Equal(t, fmt.Sprintf("f%05d", domain.MaxTreeEntries-1), tree.Entries[domain.MaxTreeEntries-1].Name)
713 699
714- small, err := g.GetTree(ctx, f.name, domain.Revision{}, domain.TreePath{}, false)700+ small, err := g.GetTree(ctx, f.Name, domain.Revision{}, domain.TreePath{}, false)
715 require.NoError(t, err)701 require.NoError(t, err)
716 assert.False(t, small.Truncated)702 assert.False(t, small.Truncated)
717 }703 }
A internal/readertest/fixture.go +170 -0
@@ -0,0 +1,170 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package readertest is the shared test kit of the RepoReader adapters: Git
4+// fixtures built with the git binary, the conformance table every adapter must
5+// pass, and the comparison of one adapter with another.
6+package readertest
7+
8+import (
9+ "fmt"
10+ "math/rand/v2"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+// Fixture builds a repository with real git: a bare repository in the
24+// data directory, and a work tree beside it that the helpers edit and commit.
25+type Fixture struct {
26+ T testing.TB
27+ Name domain.RepoName
28+ Bare string
29+ Work string
30+ When time.Time
31+}
32+
33+// NewFixture creates an empty bare repository repos/<name>.git whose HEAD
34+// is refs/heads/main.
35+func NewFixture(t testing.TB, data, repo string) *Fixture {
36+ t.Helper()
37+ f := &Fixture{
38+ T: t,
39+ Name: RepoName(t, repo),
40+ Bare: filepath.Join(data, "repos", repo+".git"),
41+ Work: t.TempDir(),
42+ When: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
43+ }
44+ out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.Bare).CombinedOutput()
45+ require.NoError(t, err, string(out))
46+ f.Git("config", "core.compression", "0")
47+ f.Git("config", "core.autocrlf", "false")
48+ return f
49+}
50+
51+// Git runs git against the bare repository and the work tree.
52+func (f *Fixture) Git(args ...string) string {
53+ f.T.Helper()
54+ cmd := exec.Command("git", args...)
55+ cmd.Dir = f.Work
56+ cmd.Env = []string{
57+ "PATH=" + os.Getenv("PATH"),
58+ "HOME=" + f.Work,
59+ "GIT_CONFIG_NOSYSTEM=1",
60+ "GIT_CONFIG_GLOBAL=/dev/null",
61+ "GIT_DIR=" + f.Bare,
62+ "GIT_WORK_TREE=" + f.Work,
63+ "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org",
64+ "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org",
65+ "GIT_AUTHOR_DATE=" + f.When.Format(time.RFC3339),
66+ "GIT_COMMITTER_DATE=" + f.When.Format(time.RFC3339),
67+ }
68+ out, err := cmd.CombinedOutput()
69+ require.NoError(f.T, err, "git %s: %s", strings.Join(args, " "), out)
70+ return strings.TrimSpace(string(out))
71+}
72+
73+// Path returns the path of a file of the work tree.
74+func (f *Fixture) Path(p string) string { return filepath.Join(f.Work, filepath.FromSlash(p)) }
75+
76+// Write creates or replaces a file with the content.
77+func (f *Fixture) Write(p, content string) *Fixture {
78+ f.T.Helper()
79+ require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(p)), 0o755))
80+ require.NoError(f.T, os.WriteFile(f.Path(p), []byte(content), 0o644))
81+ return f
82+}
83+
84+// WriteBytes is Write for binary content.
85+func (f *Fixture) WriteBytes(p string, content []byte) *Fixture {
86+ f.T.Helper()
87+ require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(p)), 0o755))
88+ require.NoError(f.T, os.WriteFile(f.Path(p), content, 0o644))
89+ return f
90+}
91+
92+// Lines writes n lines "<prefix> <i>" to the file.
93+func (f *Fixture) Lines(p, prefix string, n int) *Fixture {
94+ var b strings.Builder
95+ for i := range n {
96+ fmt.Fprintf(&b, "%s %d\n", prefix, i)
97+ }
98+ return f.Write(p, b.String())
99+}
100+
101+func (f *Fixture) Remove(p string) *Fixture {
102+ f.T.Helper()
103+ require.NoError(f.T, os.Remove(f.Path(p)))
104+ return f
105+}
106+
107+func (f *Fixture) Chmod(p string, mode os.FileMode) *Fixture {
108+ f.T.Helper()
109+ require.NoError(f.T, os.Chmod(f.Path(p), mode))
110+ return f
111+}
112+
113+func (f *Fixture) Symlink(target, p string) *Fixture {
114+ f.T.Helper()
115+ require.NoError(f.T, os.Symlink(target, f.Path(p)))
116+ return f
117+}
118+
119+func (f *Fixture) Mv(from, to string) *Fixture {
120+ f.T.Helper()
121+ require.NoError(f.T, os.MkdirAll(filepath.Dir(f.Path(to)), 0o755))
122+ require.NoError(f.T, os.Rename(f.Path(from), f.Path(to)))
123+ return f
124+}
125+
126+// Commit stages everything and commits with the next timestamp; it returns
127+// the commit id.
128+func (f *Fixture) Commit(msg string) string {
129+ f.T.Helper()
130+ f.When = f.When.Add(time.Minute)
131+ f.Git("add", "-A")
132+ f.Git("commit", "--quiet", "--allow-empty", "-m", msg)
133+ return f.Git("rev-parse", "HEAD")
134+}
135+
136+// CheckoutNew creates a branch from the current HEAD and switches to it.
137+func (f *Fixture) CheckoutNew(branch string) {
138+ f.T.Helper()
139+ f.Git("checkout", "--quiet", "-b", branch)
140+}
141+
142+func (f *Fixture) Checkout(branch string) { f.T.Helper(); f.Git("checkout", "--quiet", branch) }
143+
144+// Merge merges the branch with a merge commit.
145+func (f *Fixture) Merge(branch string) string {
146+ f.T.Helper()
147+ f.When = f.When.Add(time.Minute)
148+ f.Git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch)
149+ return f.Git("rev-parse", "HEAD")
150+}
151+
152+// RandomBytes returns n deterministic incompressible bytes.
153+func RandomBytes(n int, seed uint64) []byte {
154+ var s [32]byte
155+ for i := range 8 {
156+ s[i] = byte(seed >> (8 * i))
157+ }
158+ r := rand.NewChaCha8(s)
159+ b := make([]byte, n)
160+ _, _ = r.Read(b)
161+ return b
162+}
163+
164+// RepoName parses a repository name.
165+func RepoName(t testing.TB, s string) domain.RepoName {
166+ t.Helper()
167+ n, err := domain.ParseRepoName(s)
168+ require.NoError(t, err)
169+ return n
170+}
A internal/readertest/fixtures.go +263 -0
@@ -0,0 +1,263 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+)
13+
14+// Case is a repository the readers are compared on.
15+type Case struct {
16+ Name string
17+ Build func(t *testing.T, data string) *Fixture
18+}
19+
20+// Maintain runs what ermite runs after a push: a split commit-graph with
21+// changed-path Bloom filters.
22+func (f *Fixture) Maintain() {
23+ f.T.Helper()
24+ f.Git("commit-graph", "write", "--reachable", "--changed-paths", "--split")
25+}
26+
27+// Cases are the fixtures of the comparison: everything the gitcli tests use,
28+// plus merges, tags, deep paths, a large blob, an unborn HEAD, repositories
29+// with and without commit-graph (Bloom filters v1 and v2), and one whose
30+// graph is stale because commits were pushed after the last maintenance.
31+func Cases() []Case {
32+ return []Case{
33+ {"history", func(t *testing.T, data string) *Fixture { return NewHistory(t, data).Fixture }},
34+ {"history with commit-graph v2", func(t *testing.T, data string) *Fixture {
35+ h := NewHistory(t, data)
36+ h.Git("config", "commitGraph.changedPathsVersion", "2")
37+ h.Maintain()
38+ return h.Fixture
39+ }},
40+ {"history with commit-graph v1", func(t *testing.T, data string) *Fixture {
41+ h := NewHistory(t, data)
42+ h.Git("config", "commitGraph.changedPathsVersion", "1")
43+ h.Maintain()
44+ return h.Fixture
45+ }},
46+ {"history packed", func(t *testing.T, data string) *Fixture {
47+ h := NewHistory(t, data)
48+ h.Git("repack", "-adf", "--depth=50", "--window=250")
49+ h.Git("pack-refs", "--all")
50+ h.Maintain()
51+ return h.Fixture
52+ }},
53+ {"unborn HEAD", func(t *testing.T, data string) *Fixture { return NewFixture(t, data, "empty") }},
54+ {"stale commit-graph", buildStale},
55+ {"merges", buildMerges},
56+ {"deep paths", buildDeep},
57+ {"large blobs", buildLarge},
58+ {"names and messages", buildNames},
59+ {"refs", buildRefs},
60+ {"submodule", buildSubmodule},
61+ {"wide directory", buildWide},
62+ {"detached HEAD", func(t *testing.T, data string) *Fixture {
63+ h := NewHistory(t, data)
64+ h.Git("update-ref", "--no-deref", "HEAD", h.C2)
65+ return h.Fixture
66+ }},
67+ }
68+}
69+
70+// buildStale writes the graph, then adds commits and refs that it does not
71+// know: a push followed by maintenance that has not run yet.
72+func buildStale(t *testing.T, data string) *Fixture {
73+ f := NewFixture(t, data, "stale")
74+ f.Git("config", "commitGraph.changedPathsVersion", "2")
75+ for i := range 12 {
76+ f.Write(fmt.Sprintf("dir%d/file.txt", i%3), fmt.Sprintf("v%d\n", i)).Write("root.txt", fmt.Sprintf("r%d\n", i/4))
77+ f.Commit(fmt.Sprintf("commit %d", i))
78+ }
79+ f.Maintain()
80+ f.Git("config", "commitGraph.changedPathsVersion", "1")
81+ f.CheckoutNew("late")
82+ for i := range 4 {
83+ f.Write("dir1/file.txt", fmt.Sprintf("late%d\n", i)).Write(fmt.Sprintf("late%d.txt", i), "x\n")
84+ f.Commit(fmt.Sprintf("late %d", i))
85+ }
86+ f.Checkout("main")
87+ f.Write("dir0/new.txt", "n\n")
88+ f.Commit("after maintenance")
89+ f.Merge("late")
90+ return f
91+}
92+
93+// buildMerges has criss-cross merges, an octopus merge, a merge that takes
94+// one side of a file and one that is TREESAME to a parent, so that the path
95+// history simplification matters.
96+func buildMerges(t *testing.T, data string) *Fixture {
97+ f := NewFixture(t, data, "merges")
98+ f.Write("shared.txt", "base\n").Write("a/one.txt", "1\n").Write("b/one.txt", "1\n")
99+ f.Commit("base")
100+ f.CheckoutNew("left")
101+ f.Write("a/one.txt", "left\n")
102+ f.Commit("left a")
103+ f.Checkout("main")
104+ f.CheckoutNew("right")
105+ f.Write("b/one.txt", "right\n").Write("shared.txt", "right\n")
106+ f.Commit("right b")
107+ f.Checkout("main")
108+ f.Write("main.txt", "m\n")
109+ f.Commit("main")
110+ f.Merge("left")
111+ f.Merge("right")
112+ f.CheckoutNew("x1")
113+ f.Write("x.txt", "x1\n")
114+ f.Commit("x1")
115+ f.Checkout("main")
116+ f.CheckoutNew("x2")
117+ f.Write("y.txt", "y\n")
118+ f.Commit("x2")
119+ f.Checkout("main")
120+ f.Write("main.txt", "m2\n")
121+ f.Commit("main again")
122+ f.When = f.When.Add(time.Minute)
123+ f.Git("merge", "--quiet", "--no-ff", "-m", "octopus", "x1", "x2")
124+ // A merge whose result is the same as its first parent for shared.txt.
125+ f.CheckoutNew("same")
126+ f.Write("shared.txt", "same\n")
127+ f.Commit("same 1")
128+ f.Checkout("main")
129+ f.Git("merge", "--quiet", "--no-ff", "-s", "ours", "-m", "ours merge", "same")
130+ f.Write("tail.txt", "t\n")
131+ f.Commit("tail")
132+ f.Maintain()
133+ return f
134+}
135+
136+func buildDeep(t *testing.T, data string) *Fixture {
137+ f := NewFixture(t, data, "deep")
138+ deep := strings.Repeat("d/", 30) + "leaf"
139+ f.Write(deep+"/file.txt", "deep\n").Write("top.txt", "top\n")
140+ f.Commit("deep")
141+ f.Write(deep+"/file.txt", "deeper\n")
142+ f.Commit("change deep")
143+ f.Write("top.txt", "top2\n")
144+ f.Commit("change top")
145+ f.Write("héllo/ünï.txt", "utf8\n").Write("sp ace/a b.txt", "sp\n")
146+ f.Commit("odd names")
147+ f.Maintain()
148+ return f
149+}
150+
151+// buildLarge has blobs on both sides of the in-memory threshold of gitcli
152+// (256 KiB), loose, packed as is and packed as deltas.
153+func buildLarge(t *testing.T, data string) *Fixture {
154+ f := NewFixture(t, data, "large")
155+ big := RandomBytes(12<<20, 9)
156+ f.WriteBytes("big.bin", big).WriteBytes("mid.bin", RandomBytes(300<<10, 2)).
157+ Write("small.txt", "s\n")
158+ var text strings.Builder
159+ for i := range 20000 {
160+ fmt.Fprintf(&text, "line %d of a text that deltifies well\n", i)
161+ }
162+ f.Write("text.txt", text.String())
163+ f.Commit("big")
164+ f.Write("text.txt", text.String()+"one more line\n")
165+ f.Commit("big text, second version")
166+ f.Git("repack", "-adf", "--depth=50", "--window=250")
167+ f.Write("loose.bin", string(RandomBytes(400<<10, 4)))
168+ f.Commit("loose")
169+ f.Maintain()
170+ return f
171+}
172+
173+// buildNames has messages and names git must convert or keep: no message,
174+// non-UTF-8, an encoding header, trailing blank lines, long summaries, CRLF.
175+func buildNames(t *testing.T, data string) *Fixture {
176+ f := NewFixture(t, data, "names")
177+ f.Write("a.txt", "a\n")
178+ f.When = f.When.Add(time.Minute)
179+ f.Git("add", "-A")
180+ f.Git("commit", "--quiet", "--allow-empty-message", "-m", "")
181+ f.Write("a.txt", "b\n")
182+ f.Commit("subject\n\n\n\nbody\n\n\n")
183+ f.Write("a.txt", "c\n")
184+ f.Commit("caf\xe9 latin-1 message")
185+ f.Write("a.txt", "d\n")
186+ f.Commit("crlf\r\nsecond\r\n")
187+ f.Write("a.txt", "e\n")
188+ f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "add", "-A")
189+ f.When = f.When.Add(time.Minute)
190+ f.Git("-c", "i18n.commitEncoding=ISO-8859-1", "commit", "--quiet", "-m", "enc: caf\xe9")
191+ f.Write("a.txt", "f\n")
192+ f.When = f.When.Add(time.Minute)
193+ f.Git("add", "-A")
194+ f.Git("-c", "user.name=Zoë <Ünï>", "commit", "--quiet", "--author=Zoë Ünï <zoe@example.org>", "-m", strings.Repeat("long ", 400))
195+ f.Write("héllo wörld.txt", "x\n").Write("tab\tname.txt", "t\n").Write("-dash", "d\n")
196+ f.Commit("names")
197+ f.Maintain()
198+ return f
199+}
200+
201+// buildRefs has a branch and a tag with the same name, a branch named like a
202+// full commit id, a tag named like an abbreviation, nested tags, tags of
203+// non-commits, packed and loose refs, and refs outside heads and tags.
204+func buildRefs(t *testing.T, data string) *Fixture {
205+ f := NewFixture(t, data, "refs")
206+ f.Write("a.txt", "1\n")
207+ c1 := f.Commit("one")
208+ f.Write("a.txt", "2\n")
209+ c2 := f.Commit("two")
210+ f.Write("a.txt", "3\n")
211+ c3 := f.Commit("three")
212+ f.Git("branch", "same", c1)
213+ f.Git("tag", "same", c2)
214+ f.Git("tag", "-a", "-m", "annotated", "ann", c2)
215+ f.Git("tag", "-a", "-m", "nested", "ann2", "ann")
216+ f.Git("tag", "-a", "-m", "nested 3", "ann3", "ann2")
217+ f.Git("tag", "-a", "-m", "tree", "tree-tag", c3+"^{tree}")
218+ f.Git("tag", "-a", "-m", "blob", "blob-tag", c3+":a.txt")
219+ f.Git("-c", "advice.objectNameWarning=false", "branch", c1, c3)
220+ f.Git("-c", "advice.objectNameWarning=false", "tag", c2, c3)
221+ ghost := strings.Repeat("cd", 20)
222+ f.Git("-c", "advice.objectNameWarning=false", "branch", ghost, c3)
223+ f.Git("-c", "advice.objectNameWarning=false", "tag", strings.Repeat("ef", 20), c1)
224+ f.Git("branch", "dir/sub/branch", c2)
225+ f.Git("update-ref", "refs/remotes/origin/main", c1)
226+ f.Git("update-ref", "refs/notes/x", c1)
227+ f.Git("pack-refs", "--all")
228+ f.Git("branch", "loose-after-pack", c3)
229+ f.Git("tag", "loose-tag", c1)
230+ f.Git("branch", "-f", "same", c3) // loose overrides the packed one
231+ return f
232+}
233+
234+// buildSubmodule has a gitlink entry, a symlink and an executable.
235+func buildSubmodule(t *testing.T, data string) *Fixture {
236+ f := NewFixture(t, data, "submod")
237+ f.Write("a.txt", "a\n").Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("a.txt", "link")
238+ f.Commit("base")
239+ f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod")
240+ f.Git("commit", "--quiet", "-m", "add submodule")
241+ f.Maintain()
242+ return f
243+}
244+
245+// buildWide has a directory over MaxTreeEntries entries and one over
246+// MaxLastCommitEntries, with commits that touch some of them.
247+func buildWide(t *testing.T, data string) *Fixture {
248+ f := NewFixture(t, data, "wide")
249+ for i := range domain.MaxTreeEntries + 7 {
250+ f.Write(fmt.Sprintf("d/f%05d", i), "x")
251+ }
252+ for i := range domain.MaxLastCommitEntries + 20 {
253+ f.Write(fmt.Sprintf("m/e%03d", i), "x\n")
254+ if i%7 == 0 {
255+ f.Write(fmt.Sprintf("m/sub%03d/x", i), "x\n")
256+ }
257+ }
258+ f.Commit("wide")
259+ f.Write("m/e003", "changed\n").Write("m/e250", "changed\n").Write("d/f00010", "y")
260+ f.Commit("touch some")
261+ f.Maintain()
262+ return f
263+}
A internal/readertest/history.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import "testing"
6+
7+// History is the repository of the read tests: three commits on main, a
8+// branch with a slash, a merge, lightweight and annotated tags.
9+type History struct {
10+ *Fixture
11+ C1, C2, C3, Side, Merge string
12+}
13+
14+func NewHistory(t *testing.T, data string) *History {
15+ t.Helper()
16+ f := NewFixture(t, data, "hist")
17+ h := &History{Fixture: f}
18+ f.Write("a.txt", "one\ntwo\nthree\n").
19+ WriteBytes("bin", []byte{0, 1}).
20+ Write("d/f", "hi\n").
21+ Write("d/sub/g", "deep\n").
22+ Write("we:ird*.txt", "x").
23+ Write("README.md", "# hello\n").
24+ Write("empty", "")
25+ h.C1 = f.Commit("first\n\nbody of the first commit\n")
26+
27+ f.Mv("a.txt", "b.txt").Write("b.txt", "one\ntwo\nthree\nfour\n").
28+ WriteBytes("bin", []byte{0, 2}).
29+ Chmod("d/f", 0o755).
30+ Write("n.txt", "new\nnonl").
31+ Remove("README.md")
32+ h.C2 = f.Commit("second")
33+
34+ f.Remove("n.txt").Symlink("b.txt", "n.txt")
35+ h.C3 = f.Commit("third: type change")
36+
37+ f.CheckoutNew("feature/x")
38+ f.Write("side.txt", "s\n")
39+ h.Side = f.Commit("side work")
40+ f.Checkout("main")
41+ f.Write("main.txt", "m\n")
42+ f.Commit("main work")
43+ h.Merge = f.Merge("feature/x")
44+
45+ f.Git("tag", "v1", h.C1)
46+ f.Git("tag", "-a", "-m", "release two", "v2", h.C2)
47+ f.Git("tag", "-a", "-m", "tag of a tag", "v2-again", "v2")
48+ f.Git("tag", "-a", "-m", "tag of a tree", "tree-tag", h.Merge+"^{tree}")
49+ f.Git("branch", "release/1.0", h.C2)
50+ f.Git("branch", "rel/1", h.C1)
51+ f.Git("tag", "rel/1/x", h.C3)
52+ return h
53+}

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