main 1fb2421

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	"strconv"
11	"strings"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/adapters/gitremote"
19	"bnema.dev/ermite/internal/domain"
20)
21
22func gitBin(t *testing.T) string {
23	t.Helper()
24	bin, err := exec.LookPath("git")
25	if err != nil {
26		t.Skip("git is not installed")
27	}
28	return bin
29}
30
31func run(t *testing.T, dir string, args ...string) string {
32	t.Helper()
33	cmd := exec.Command("git", args...)
34	cmd.Dir = dir
35	cmd.Env = []string{
36		"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
37		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x",
38	}
39	out, err := cmd.CombinedOutput()
40	require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out)
41	return strings.TrimSpace(string(out))
42}
43
44// fixture builds <base>/o/r with a branch, a lightweight tag and an annotated
45// tag, and returns the base as a file:// URL.
46func fixture(t *testing.T) (base, commit, tagObj string) {
47	t.Helper()
48	root := t.TempDir()
49	repo := filepath.Join(root, "o", "r")
50	require.NoError(t, os.MkdirAll(repo, 0o755))
51	run(t, repo, "init", "-q", "--initial-branch=main")
52	require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644))
53	run(t, repo, "add", "a")
54	run(t, repo, "commit", "-q", "-m", "a")
55	commit = run(t, repo, "rev-parse", "HEAD")
56	run(t, repo, "tag", "light")
57	run(t, repo, "tag", "-a", "-m", "annotated", "v1")
58	tagObj = run(t, repo, "rev-parse", "refs/tags/v1")
59	return "file://" + root, commit, tagObj
60}
61
62func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote {
63	t.Helper()
64	cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"}
65	if mut != nil {
66		mut(&cfg)
67	}
68	return gitremote.New(cfg)
69}
70
71func TestListRefsPeelsAnnotatedTags(t *testing.T) {
72	base, commit, tagObj := fixture(t)
73	refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r")
74	require.NoError(t, err)
75	byName := map[string][2]string{}
76	for _, r := range refs {
77		byName[r.Name] = [2]string{r.ID, r.Peeled}
78	}
79	assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"])
80	assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"])
81	assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled")
82	assert.Len(t, refs, 3)
83}
84
85// github.com answers a repository that does not exist, or that is private, with
86// a 404 that git reports as a missing repository or as a prompt it may not
87// show; both are "repository not found". Any other failure is unavailable.
88func TestListRefsMapsWhatGitSays(t *testing.T) {
89	fake := func(stderr string) string {
90		bin := filepath.Join(t.TempDir(), "git")
91		require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755))
92		return bin
93	}
94	for _, tc := range []struct {
95		stderr string
96		want   error
97	}{
98		{"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound},
99		{"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound},
100		{"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable},
101		{"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable},
102	} {
103		_, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r")
104		assert.ErrorIs(t, err, tc.want, tc.stderr)
105	}
106}
107
108func TestListRefsRefusesBadNames(t *testing.T) {
109	r := newRemote(t, "file:///nonexistent", nil)
110	for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} {
111		_, err := r.ListRefs(context.Background(), bad[0], bad[1])
112		assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1])
113	}
114}
115
116func TestListRefsBoundsTheOutput(t *testing.T) {
117	base, _, _ := fixture(t)
118	_, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r")
119	assert.ErrorIs(t, err, domain.ErrUnavailable)
120}
121
122func TestListRefsTimesOut(t *testing.T) {
123	bin := filepath.Join(t.TempDir(), "git")
124	require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
125	start := time.Now()
126	_, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 400*time.Millisecond }).ListRefs(context.Background(), "o", "r")
127	assert.ErrorIs(t, err, domain.ErrUnavailable)
128	// One overall deadline, both attempts and the clean-up inside it.
129	assert.Less(t, time.Since(start), 1*time.Second)
130}
131
132// dead tells whether the process pid is gone or a zombie (not reaped yet, as
133// under a subreaper that does not wait): either way it runs no more.
134func dead(pid int) bool {
135	raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
136	if err != nil {
137		return true
138	}
139	// "pid (comm) S ...": comm may hold spaces and parentheses.
140	s := string(raw)
141	i := strings.LastIndexByte(s, ')')
142	return i < 0 || len(s) < i+3 || s[i+2] == 'Z' || s[i+2] == 'X'
143}
144
145// git runs git-remote-https, the one that connects: when the time is up it is
146// killed too, and does not outlive the call. Each attempt starts one.
147func TestListRefsKillsTheHelpersOfGit(t *testing.T) {
148	if _, err := os.Stat("/proc/self/stat"); err != nil {
149		t.Skip("needs /proc")
150	}
151	dir := t.TempDir()
152	pids := filepath.Join(dir, "pids")
153	bin := filepath.Join(dir, "git")
154	script := "#!/bin/sh\nsleep 30 &\necho $! >> '" + pids + "'\nwait\n"
155	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
156	_, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 600*time.Millisecond }).ListRefs(context.Background(), "o", "r")
157	assert.ErrorIs(t, err, domain.ErrUnavailable)
158	raw, rerr := os.ReadFile(pids)
159	require.NoError(t, rerr)
160	fields := strings.Fields(string(raw))
161	require.Len(t, fields, 2, "one helper per attempt")
162	for _, f := range fields {
163		pid, aerr := strconv.Atoi(f)
164		require.NoError(t, aerr)
165		assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond, "the helper %d of git is still running", pid)
166	}
167}
168
169// A helper that left the group (setsid) and keeps the output of git open
170// cannot hold the call past its Timeout: the clean-up is inside the budget.
171func TestListRefsStaysInsideTimeoutWithAHelperHoldingTheOutput(t *testing.T) {
172	setsid, err := exec.LookPath("setsid")
173	if err != nil {
174		t.Skip("needs setsid")
175	}
176	dir := t.TempDir()
177	pids := filepath.Join(dir, "pids")
178	bin := filepath.Join(dir, "git")
179	script := "#!/bin/sh\n'" + setsid + "' sleep 30 &\necho $! >> '" + pids + "'\nwait\n"
180	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
181	t.Cleanup(func() {
182		raw, _ := os.ReadFile(pids)
183		for _, f := range strings.Fields(string(raw)) {
184			if pid, err := strconv.Atoi(f); err == nil {
185				if p, err := os.FindProcess(pid); err == nil {
186					_ = p.Kill()
187				}
188			}
189		}
190	})
191	const timeout = 400 * time.Millisecond
192	r := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, timeout })
193	start := time.Now()
194	_, err = r.ListRefs(context.Background(), "o", "r")
195	assert.ErrorIs(t, err, domain.ErrUnavailable)
196	// Generous for a loaded CI, still far below a clean-up outside the budget
197	// (a full WaitDelay of seconds).
198	assert.LessOrEqual(t, time.Since(start), timeout+250*time.Millisecond)
199}
200
201// The helper of a git that exits by itself does not outlive it either.
202func TestListRefsKillsTheHelpersOfAGitThatExited(t *testing.T) {
203	if _, err := os.Stat("/proc/self/stat"); err != nil {
204		t.Skip("needs /proc")
205	}
206	dir := t.TempDir()
207	pidFile := filepath.Join(dir, "pid")
208	bin := filepath.Join(dir, "git")
209	script := "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\necho $! > '" + pidFile + "'\nexit 0\n"
210	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
211	_, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
212	require.NoError(t, err)
213	raw, rerr := os.ReadFile(pidFile)
214	require.NoError(t, rerr)
215	pid, aerr := strconv.Atoi(strings.TrimSpace(string(raw)))
216	require.NoError(t, aerr)
217	assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond)
218}
219
220// A connection github.com never answers is run again within the same bound,
221// and the second one can succeed.
222func TestListRefsRunsAgainAfterATimeOut(t *testing.T) {
223	dir := t.TempDir()
224	bin := filepath.Join(dir, "git")
225	id := strings.Repeat("a", domain.ObjectIDLen)
226	script := "#!/bin/sh\nif [ ! -e '" + dir + "/seen' ]; then touch '" + dir + "/seen'; sleep 30; fi\nprintf '" + id + "\\trefs/heads/main\\n'\n"
227	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
228	start := time.Now()
229	refs, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 1000*time.Millisecond }).ListRefs(context.Background(), "o", "r")
230	require.NoError(t, err)
231	require.Len(t, refs, 1)
232	assert.Equal(t, "refs/heads/main", refs[0].Name)
233	assert.Less(t, time.Since(start), 1500*time.Millisecond)
234}
235
236func TestListRefsStopsWhenTheCallerGivesUp(t *testing.T) {
237	bin := filepath.Join(t.TempDir(), "git")
238	require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
239	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
240	defer cancel()
241	start := time.Now()
242	_, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(ctx, "o", "r")
243	assert.ErrorIs(t, err, context.DeadlineExceeded)
244	assert.Less(t, time.Since(start), 1500*time.Millisecond)
245}
246
247// git asks for no credentials and runs with no configuration of the machine:
248// the environment it gets is only what the adapter builds.
249func TestListRefsGitEnvironment(t *testing.T) {
250	out := filepath.Join(t.TempDir(), "env")
251	bin := filepath.Join(t.TempDir(), "git")
252	script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n"
253	require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
254	t.Setenv("GITHUB_TOKEN", "secret-from-the-process")
255	_, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r")
256	require.NoError(t, err)
257	b, err := os.ReadFile(out)
258	require.NoError(t, err)
259	env := string(b)
260	assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0")
261	assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https")
262	assert.NotContains(t, env, "secret-from-the-process")
263	assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
264	assert.Contains(t, env, "credential.helper=")
265}
266
267// GO-008: the proxy variables of ermite reach git, and nothing else of its
268// environment does.
269func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
270	out := filepath.Join(t.TempDir(), "env")
271	bin := filepath.Join(t.TempDir(), "git")
272	require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
273	t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
274	t.Setenv("no_proxy", "localhost,.internal")
275	t.Setenv("HTTP_PROXY", "")
276	t.Setenv("GITHUB_TOKEN", "not-for-git")
277
278	proxy := gitremote.ProxyEnv(os.Getenv)
279	assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
280
281	_, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
282	require.NoError(t, err)
283	b, err := os.ReadFile(out)
284	require.NoError(t, err)
285	env := string(b)
286	assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
287	assert.Contains(t, env, "no_proxy=localhost,.internal")
288	assert.NotContains(t, env, "not-for-git")
289	assert.NotContains(t, env, "HTTP_PROXY=")
290
291	// Without Config.Proxy nothing is forwarded, whatever the process has.
292	_, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
293	require.NoError(t, err)
294	b, _ = os.ReadFile(out)
295	assert.NotContains(t, string(b), "proxy.internal")
296}

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