detached 59d5a0b

compare.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
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.
24type 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.
34var SmallBudget = Budget{Revs: 24, LogRevs: 8, Dirs: 10, Files: 10, LogPaths: 6, LastTrees: 6}
35
36// LargeBudget suits a repository of thousands of commits.
37var 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.
41func 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, domain.ErrInvalidObjectID):
56		return "invalid object id"
57	case errors.Is(err, domain.ErrInvalidRevision):
58		return "invalid revision"
59	case errors.Is(err, domain.ErrInvalidPageToken):
60		return "invalid page token"
61	case errors.Is(err, domain.ErrInvalidRefKind):
62		return "invalid ref kind"
63	case errors.Is(err, domain.ErrInvalid):
64		return "invalid argument"
65	case errors.Is(err, context.Canceled):
66		return "canceled"
67	}
68	return "other" // outside the domain errors: the messages of two readers differ
69}
70
71// Queries are the questions put to both readers about one repository.
72type Queries struct {
73	Revs     []string // revisions as a client writes them
74	LogRevs  []string
75	Dirs     []string
76	Files    []string
77	LogPaths []string
78	RefPaths []string
79}
80
81// GitOut runs git in dir and returns its trimmed output.
82func GitOut(t testing.TB, dir string, args ...string) string {
83	t.Helper()
84	cmd := exec.Command("git", append([]string{"--git-dir=" + dir}, args...)...)
85	cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "LC_ALL=C"}
86	out, err := cmd.Output()
87	require.NoError(t, err, "git %s", strings.Join(args, " "))
88	return strings.TrimRight(string(out), "\n")
89}
90
91func lines(s string) []string {
92	if s == "" {
93		return nil
94	}
95	return strings.Split(s, "\n")
96}
97
98// spread picks at most n elements of s, evenly, always the first.
99func spread[T any](s []T, n int) []T {
100	if len(s) <= n || n <= 0 {
101		return s
102	}
103	out := make([]T, 0, n)
104	for i := range n {
105		out = append(out, s[i*len(s)/n])
106	}
107	return out
108}
109
110// Derive builds the questions for the repository in dir with git.
111func Derive(t testing.TB, dir string, b Budget) Queries {
112	t.Helper()
113	var q Queries
114	revs := []string{"", "HEAD", "nope", "deadbeefdeadbeef", strings.Repeat("ab", 20), "refs/heads", "refs/nope/x"}
115	for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")) {
116		revs = append(revs, l)
117		if short, ok := strings.CutPrefix(l, "refs/heads/"); ok {
118			revs = append(revs, short)
119		} else if short, ok := strings.CutPrefix(l, "refs/tags/"); ok {
120			revs = append(revs, short)
121		}
122	}
123	for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags")) {
124		revs = append(revs, l, l[:7], l[:12]) // tag objects too: they are not commits when they lead elsewhere
125	}
126	var commits []string
127	{
128		// An empty repository has no commit: rev-list --all prints nothing.
129		cmd := exec.Command("git", "--git-dir="+dir, "rev-list", "--all", "--missing=allow-any")
130		cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
131		out, err := cmd.Output()
132		require.NoError(t, err)
133		commits = lines(strings.TrimRight(string(out), "\n"))
134	}
135	for _, c := range spread(commits, b.Revs) {
136		revs = append(revs, c, c[:7], c[:10])
137	}
138	if len(commits) > 0 {
139		tree := GitOut(t, dir, "rev-parse", commits[0]+"^{tree}")
140		revs = append(revs, tree, tree[:8]) // a tree is not a commit
141	}
142	q.Revs = dedupe(revs)
143	var logRevs []string
144	for _, r := range q.Revs {
145		if r == "" || r == "HEAD" || strings.HasPrefix(r, "refs/") || len(r) == 40 {
146			logRevs = append(logRevs, r)
147		}
148	}
149	q.LogRevs = spread(logRevs, b.LogRevs+4)
150
151	// Paths of the newest commit of HEAD, or of the first commit.
152	if len(commits) > 0 {
153		var dirs, files []string
154		for _, l := range lines(GitOut(t, dir, "ls-tree", "-r", "-t", "--format=%(objecttype) %(path)", commits[0])) {
155			typ, p, _ := strings.Cut(l, " ")
156			switch typ {
157			case "tree":
158				dirs = append(dirs, p)
159			case "blob":
160				files = append(files, p)
161			}
162		}
163		q.Dirs = append([]string{""}, spread(dirs, b.Dirs)...)
164		q.Dirs = append(q.Dirs, "nope", "nope/deeper", "*")
165		if len(files) > 0 {
166			q.Dirs = append(q.Dirs, files[0]) // a file where a directory is wanted
167		}
168		q.Files = append(spread(files, b.Files), "", "nope", "nope/x")
169		if len(dirs) > 0 {
170			q.Files = append(q.Files, dirs[0]) // a directory where a file is wanted
171		}
172		q.LogPaths = append([]string{""}, spread(files, b.LogPaths/2+1)...)
173		q.LogPaths = append(q.LogPaths, spread(dirs, b.LogPaths/2+1)...)
174		q.LogPaths = append(q.LogPaths, "nope", "*")
175		q.LogPaths = dedupe(q.LogPaths)
176	}
177	for _, r := range q.Revs {
178		if r != "" {
179			q.RefPaths = append(q.RefPaths, r, r+"/dir/file", r+"/x")
180		}
181	}
182	q.RefPaths = append(q.RefPaths, "", "/", "nope", "nope/x", "a//b", "../x", "main/", "HEAD/x", "refs/heads/main/x")
183	q.RefPaths = dedupe(spread(q.RefPaths, 4*b.Revs+12))
184	return q
185}
186
187func dedupe(s []string) []string {
188	seen := make(map[string]bool, len(s))
189	out := s[:0:0]
190	for _, v := range s {
191		if !seen[v] {
192			seen[v] = true
193			out = append(out, v)
194		}
195	}
196	return out
197}
198
199// Compare asks both readers the same questions about the repository and
200// requires the same answers: the same values, and the same kind of error. want
201// is the oracle (the CLI reader).
202func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir string, b Budget) {
203	t.Helper()
204	ctx := context.Background()
205	q := Derive(t, dir, b)
206	t.Logf("%d revisions, %d dirs, %d files, %d log paths", len(q.Revs), len(q.Dirs), len(q.Files), len(q.LogPaths))
207
208	t.Run("ListRefs", func(t *testing.T) {
209		w, werr := want.ListRefs(ctx, repo)
210		g, gerr := got.ListRefs(ctx, repo)
211		require.Equal(t, errClass(werr), errClass(gerr))
212		assert.Equal(t, w, g)
213	})
214	t.Run("ResolveRevision", func(t *testing.T) {
215		for _, s := range q.Revs {
216			rev, err := domain.ParseRevision(s)
217			if err != nil {
218				continue
219			}
220			w, werr := want.ResolveRevision(ctx, repo, rev)
221			g, gerr := got.ResolveRevision(ctx, repo, rev)
222			if assert.Equal(t, errClass(werr), errClass(gerr), "revision %q", s) {
223				assert.Equal(t, w, g, "revision %q", s)
224			}
225		}
226	})
227	t.Run("ResolveRefPath", func(t *testing.T) {
228		for _, p := range q.RefPaths {
229			w, werr := want.ResolveRefPath(ctx, repo, p)
230			g, gerr := got.ResolveRefPath(ctx, repo, p)
231			if assert.Equal(t, errClass(werr), errClass(gerr), "path %q", p) {
232				assert.Equal(t, w, g, "path %q", p)
233			}
234		}
235	})
236	revs := make([]domain.Revision, 0, len(q.Revs))
237	for _, s := range spread(q.Revs, b.Revs+8) {
238		if rev, err := domain.ParseRevision(s); err == nil {
239			revs = append(revs, rev)
240		}
241	}
242	t.Run("GetTree", func(t *testing.T) {
243		last := 0
244		for _, rev := range revs {
245			for _, p := range q.Dirs {
246				path, err := domain.ParseTreePath(p)
247				if err != nil {
248					continue
249				}
250				w, werr := want.GetTree(ctx, repo, rev, path, false)
251				g, gerr := got.GetTree(ctx, repo, rev, path, false)
252				if assert.Equal(t, errClass(werr), errClass(gerr), "tree %q at %q", p, rev) {
253					assert.Equal(t, w, g, "tree %q at %q", p, rev)
254				}
255				// Last commits for the first few revisions only: each is a history walk.
256				if werr == nil && last < b.LastTrees*3 && len(w.Entries) > 0 {
257					last++
258					w, werr := want.GetTree(ctx, repo, rev, path, true)
259					g, gerr := got.GetTree(ctx, repo, rev, path, true)
260					if assert.Equal(t, errClass(werr), errClass(gerr), "tree+last %q at %q", p, rev) {
261						assert.Equal(t, w, g, "tree+last %q at %q", p, rev)
262					}
263				}
264			}
265		}
266	})
267	t.Run("ListPaths", func(t *testing.T) {
268		for _, rev := range revs {
269			for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} {
270				w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1])
271				g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1])
272				if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) {
273					assert.Equal(t, w, g, "paths at %q %v", rev, lim)
274				}
275			}
276		}
277	})
278	t.Run("OpenBlob", func(t *testing.T) {
279		read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
280			info, rc, err := r.OpenBlob(ctx, repo, rev, path)
281			if err != nil {
282				require.Nil(t, rc)
283				return info, nil, err
284			}
285			body, rerr := io.ReadAll(rc)
286			require.NoError(t, rerr)
287			require.NoError(t, rc.Close())
288			return info, body, nil
289		}
290		for _, rev := range spread(revs, b.Revs/2+4) {
291			for _, p := range q.Files {
292				path, err := domain.ParseTreePath(p)
293				if err != nil {
294					continue
295				}
296				wi, wb, werr := read(want, rev, path)
297				gi, gb, gerr := read(got, rev, path)
298				if assert.Equal(t, errClass(werr), errClass(gerr), "blob %q at %q", p, rev) && werr == nil {
299					assert.Equal(t, wi, gi, "blob %q at %q", p, rev)
300					assert.True(t, bytes.Equal(wb, gb), "content of %q at %q differs", p, rev)
301				}
302			}
303		}
304	})
305	t.Run("ListCommits", func(t *testing.T) {
306		pages := [...][2]int{{0, 1000}, {0, 3}, {2, 3}, {5, 2}, {40, 7}, {5000, 20}}
307		ids := func(p ports.CommitPage) string {
308			var sb strings.Builder
309			for _, c := range p.Commits {
310				sb.WriteString(c.ID.String()[:8] + " ")
311			}
312			return sb.String()
313		}
314		for _, s := range q.LogRevs {
315			rev, err := domain.ParseRevision(s)
316			if err != nil {
317				continue
318			}
319			for _, p := range q.LogPaths {
320				path, err := domain.ParseTreePath(p)
321				if err != nil {
322					continue
323				}
324				for _, pg := range pages {
325					cq := ports.CommitQuery{Revision: rev, Path: path, Skip: pg[0], Limit: pg[1]}
326					w, werr := want.ListCommits(ctx, repo, cq)
327					g, gerr := got.ListCommits(ctx, repo, cq)
328					if assert.Equal(t, errClass(werr), errClass(gerr), "log %q -- %q %v", s, p, pg) {
329						if !assert.Equal(t, w, g, "log %q -- %q %v\nwant %s\n got %s", s, p, pg, ids(w), ids(g)) {
330							return
331						}
332					}
333				}
334			}
335		}
336		for _, cq := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
337			_, werr := want.ListCommits(ctx, repo, cq)
338			_, gerr := got.ListCommits(ctx, repo, cq)
339			assert.Equal(t, errClass(werr), errClass(gerr), "%+v", cq)
340		}
341	})
342	t.Run("missing repository", func(t *testing.T) {
343		nope := RepoName(t, "no-such-repo")
344		for name, call := range map[string]func(ports.RepoReader) error{
345			"ListRefs":        func(r ports.RepoReader) error { _, err := r.ListRefs(ctx, nope); return err },
346			"ResolveRevision": func(r ports.RepoReader) error { _, err := r.ResolveRevision(ctx, nope, domain.Revision{}); return err },
347			"ResolveRefPath":  func(r ports.RepoReader) error { _, err := r.ResolveRefPath(ctx, nope, "main"); return err },
348			"GetTree": func(r ports.RepoReader) error {
349				_, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
350				return err
351			},
352			"ListPaths": func(r ports.RepoReader) error {
353				_, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354				return err
355			},
356			"OpenBlob": func(r ports.RepoReader) error {
357				_, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
358				return err
359			},
360			"ListCommits": func(r ports.RepoReader) error {
361				_, err := r.ListCommits(ctx, nope, ports.CommitQuery{Limit: 1})
362				return err
363			},
364		} {
365			assert.Equal(t, errClass(call(want)), errClass(call(got)), name)
366		}
367	})
368}

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 45

↑↓ moveenter openesc close