v0.10.0 ce48ee3

gitremote_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitremote_test
4
5import (
6	"context"
7	"os"
8	"os/exec"
9	"path/filepath"
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/gitremote"
18	"bnema.dev/ermite/internal/domain"
19)
20
21func gitBin(t *testing.T) string {
22	t.Helper()
23	bin, err := exec.LookPath("git")
24	if err != nil {
25		t.Skip("git is not installed")
26	}
27	return bin
28}
29
30func run(t *testing.T, dir string, args ...string) string {
31	t.Helper()
32	cmd := exec.Command("git", args...)
33	cmd.Dir = dir
34	cmd.Env = []string{
35		"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
36		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x",
37	}
38	out, err := cmd.CombinedOutput()
39	require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out)
40	return strings.TrimSpace(string(out))
41}
42
43// fixture builds <base>/o/r with a branch, a lightweight tag and an annotated
44// tag, and returns the base as a file:// URL.
45func fixture(t *testing.T) (base, commit, tagObj string) {
46	t.Helper()
47	root := t.TempDir()
48	repo := filepath.Join(root, "o", "r")
49	require.NoError(t, os.MkdirAll(repo, 0o755))
50	run(t, repo, "init", "-q", "--initial-branch=main")
51	require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644))
52	run(t, repo, "add", "a")
53	run(t, repo, "commit", "-q", "-m", "a")
54	commit = run(t, repo, "rev-parse", "HEAD")
55	run(t, repo, "tag", "light")
56	run(t, repo, "tag", "-a", "-m", "annotated", "v1")
57	tagObj = run(t, repo, "rev-parse", "refs/tags/v1")
58	return "file://" + root, commit, tagObj
59}
60
61func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote {
62	t.Helper()
63	cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"}
64	if mut != nil {
65		mut(&cfg)
66	}
67	return gitremote.New(cfg)
68}
69
70func TestListRefsPeelsAnnotatedTags(t *testing.T) {
71	base, commit, tagObj := fixture(t)
72	refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r")
73	require.NoError(t, err)
74	byName := map[string][2]string{}
75	for _, r := range refs {
76		byName[r.Name] = [2]string{r.ID, r.Peeled}
77	}
78	assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"])
79	assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"])
80	assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled")
81	assert.Len(t, refs, 3)
82}
83
84// github.com answers a repository that does not exist, or that is private, with
85// a 404 that git reports as a missing repository or as a prompt it may not
86// show; both are "repository not found". Any other failure is unavailable.
87func TestListRefsMapsWhatGitSays(t *testing.T) {
88	fake := func(stderr string) string {
89		bin := filepath.Join(t.TempDir(), "git")
90		require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755))
91		return bin
92	}
93	for _, tc := range []struct {
94		stderr string
95		want   error
96	}{
97		{"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound},
98		{"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound},
99		{"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable},
100		{"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable},
101	} {
102		_, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r")
103		assert.ErrorIs(t, err, tc.want, tc.stderr)
104	}
105}
106
107func TestListRefsRefusesBadNames(t *testing.T) {
108	r := newRemote(t, "file:///nonexistent", nil)
109	for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} {
110		_, err := r.ListRefs(context.Background(), bad[0], bad[1])
111		assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1])
112	}
113}
114
115func TestListRefsBoundsTheOutput(t *testing.T) {
116	base, _, _ := fixture(t)
117	_, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r")
118	assert.ErrorIs(t, err, domain.ErrUnavailable)
119}
120
121func TestListRefsTimesOut(t *testing.T) {
122	bin := filepath.Join(t.TempDir(), "git")
123	require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
124	start := time.Now()
125	_, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 200*time.Millisecond }).ListRefs(context.Background(), "o", "r")
126	assert.ErrorIs(t, err, domain.ErrUnavailable)
127	assert.Less(t, time.Since(start), 10*time.Second)
128}
129
130// git asks for no credentials and runs with no configuration of the machine:
131// the environment it gets is only what the adapter builds.
132func TestListRefsGitEnvironment(t *testing.T) {
133	out := filepath.Join(t.TempDir(), "env")
134	bin := filepath.Join(t.TempDir(), "git")
135	script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n"
136	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
137	t.Setenv("GITHUB_TOKEN", "secret-from-the-process")
138	_, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r")
139	require.NoError(t, err)
140	b, err := os.ReadFile(out)
141	require.NoError(t, err)
142	env := string(b)
143	assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0")
144	assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https")
145	assert.NotContains(t, env, "secret-from-the-process")
146	assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
147	assert.Contains(t, env, "credential.helper=")
148}
149
150// GO-008: the proxy variables of ermite reach git, and nothing else of its
151// environment does.
152func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
153	out := filepath.Join(t.TempDir(), "env")
154	bin := filepath.Join(t.TempDir(), "git")
155	require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
156	t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
157	t.Setenv("no_proxy", "localhost,.internal")
158	t.Setenv("HTTP_PROXY", "")
159	t.Setenv("GITHUB_TOKEN", "not-for-git")
160
161	proxy := gitremote.ProxyEnv(os.Getenv)
162	assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
163
164	_, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
165	require.NoError(t, err)
166	b, err := os.ReadFile(out)
167	require.NoError(t, err)
168	env := string(b)
169	assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
170	assert.Contains(t, env, "no_proxy=localhost,.internal")
171	assert.NotContains(t, env, "not-for-git")
172	assert.NotContains(t, env, "HTTP_PROXY=")
173
174	// Without Config.Proxy nothing is forwarded, whatever the process has.
175	_, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
176	require.NoError(t, err)
177	b, _ = os.ReadFile(out)
178	assert.NotContains(t, string(b), "proxy.internal")
179}

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 3

Tags 44

↑↓ moveenter openesc close