v0.29.0 55fcc8f

backend_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6	"context"
7	"encoding/binary"
8	"os"
9	"os/exec"
10	"path/filepath"
11	"strconv"
12	"strings"
13	"testing"
14
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/adapters/gitcli"
19	"bnema.dev/ermite/internal/domain"
20)
21
22func envMap(env []string) map[string]string {
23	m := map[string]string{}
24	for _, e := range env {
25		k, v, _ := strings.Cut(e, "=")
26		m[k] = v
27	}
28	return m
29}
30
31// backendConf returns the GIT_CONFIG_KEY/VALUE pairs of a backend environment
32// and checks that GIT_CONFIG_COUNT matches them.
33func backendConf(e map[string]string) map[string]string {
34	conf := map[string]string{}
35	n := 0
36	for {
37		k, ok := e["GIT_CONFIG_KEY_"+strconv.Itoa(n)]
38		if !ok {
39			break
40		}
41		conf[k] = e["GIT_CONFIG_VALUE_"+strconv.Itoa(n)]
42		n++
43	}
44	if e["GIT_CONFIG_COUNT"] != strconv.Itoa(n) {
45		panic("GIT_CONFIG_COUNT does not match the keys")
46	}
47	return conf
48}
49
50func TestHTTPBackendReceiveMaxInputSize(t *testing.T) {
51	ctx := context.Background()
52	_, data := newGit(t)
53	bin, err := exec.LookPath("git")
54	require.NoError(t, err)
55	g := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH"), MaxReceiveBytes: 4 << 30})
56
57	push, err := g.HTTPBackend(ctx, true)
58	require.NoError(t, err)
59	assert.Equal(t, "4294967296", backendConf(envMap(push.Env))["receive.maxInputSize"])
60
61	read, err := g.HTTPBackend(ctx, false)
62	require.NoError(t, err)
63	assert.NotContains(t, backendConf(envMap(read.Env)), "receive.maxInputSize", "a fetch does not receive a pack")
64}
65
66func TestHTTPBackendEnvironment(t *testing.T) {
67	ctx := context.Background()
68	g, data := newGit(t)
69
70	read, err := g.HTTPBackend(ctx, false)
71	require.NoError(t, err)
72	execPath, err := exec.Command("git", "--exec-path").Output()
73	require.NoError(t, err)
74	assert.Equal(t, filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend"), read.Path)
75	assert.Equal(t, filepath.Join(data, "repos"), read.Dir)
76
77	e := envMap(read.Env)
78	assert.Equal(t, "1", e["GIT_CONFIG_NOSYSTEM"])
79	assert.Equal(t, filepath.Join(data, "run", "git-home"), e["HOME"])
80	assert.Equal(t, "0", e["GIT_TERMINAL_PROMPT"])
81	assert.Equal(t, filepath.Join(data, "repos"), e["GIT_PROJECT_ROOT"])
82	assert.Equal(t, "1", e["GIT_HTTP_EXPORT_ALL"])
83	assert.NotContains(t, e, "REMOTE_USER", "a read is not a push")
84
85	assert.Equal(t, map[string]string{
86		"core.hooksPath":         filepath.Join(data, "run", "hooks"),
87		"receive.fsckObjects":    "true",
88		"uploadpack.allowFilter": "true",
89		"pack.threads":           "1",
90		"pack.windowMemory":      "256m",
91	}, backendConf(e))
92
93	push, err := g.HTTPBackend(ctx, true)
94	require.NoError(t, err)
95	pe := envMap(push.Env)
96	assert.Equal(t, "owner", pe["REMOTE_USER"])
97	assert.Equal(t, map[string]string{
98		"core.hooksPath":         filepath.Join(data, "run", "hooks"),
99		"receive.fsckObjects":    "true",
100		"uploadpack.allowFilter": "true",
101		"pack.threads":           "1",
102		"pack.windowMemory":      "256m",
103		"http.receivepack":       "true",
104		"receive.hideRefs":       "refs/pull/",
105	}, backendConf(pe), "http.receivepack only for a push; no size limit unless configured")
106
107	// Nothing from the caller's environment leaks.
108	t.Setenv("GIT_DIR", "/leak")
109	again, err := g.HTTPBackend(ctx, true)
110	require.NoError(t, err)
111	assert.NotContains(t, envMap(again.Env), "GIT_DIR")
112
113	// The slice is the caller's: appending does not corrupt the next call.
114	_ = append(read.Env, "X=1")
115	third, err := g.HTTPBackend(ctx, false)
116	require.NoError(t, err)
117	assert.NotContains(t, envMap(third.Env), "X")
118}
119
120func TestHTTPBackendMissing(t *testing.T) {
121	data := t.TempDir()
122	script := writeScript(t, `case "$1" in --exec-path) echo "`+data+`/nowhere";; *) echo "git version 2.50.0";; esac`)
123	g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
124	_, err := g.HTTPBackend(context.Background(), false)
125	require.Error(t, err)
126	assert.Contains(t, err.Error(), "git-http-backend")
127
128	// A non-executable file is refused too.
129	require.NoError(t, os.MkdirAll(filepath.Join(data, "exec"), 0o755))
130	require.NoError(t, os.WriteFile(filepath.Join(data, "exec", "git-http-backend"), []byte("x"), 0o644))
131	script = writeScript(t, `echo "`+data+`/exec"`)
132	g = gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
133	_, err = g.HTTPBackend(context.Background(), false)
134	require.Error(t, err)
135	assert.Contains(t, err.Error(), "not an executable")
136}
137
138func TestDefaultBranchAndMaintain(t *testing.T) {
139	ctx := context.Background()
140	g, data := newGit(t)
141	n := name(t, "tools")
142	id, err := g.Stage(ctx)
143	require.NoError(t, err)
144	require.NoError(t, g.Publish(ctx, id, n))
145	dir := filepath.Join(data, "repos", "tools.git")
146
147	branch, exists, err := g.DefaultBranch(ctx, n)
148	require.NoError(t, err)
149	assert.Equal(t, "main", branch)
150	assert.False(t, exists, "no commit yet")
151
152	_, _, err = g.DefaultBranch(ctx, name(t, "ghost"))
153	assert.ErrorIs(t, err, domain.ErrNotFound)
154
155	// Put a commit on "dev" through a work tree.
156	wt := t.TempDir()
157	env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
158		"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
159	run := func(args ...string) {
160		cmd := exec.Command("git", args...)
161		cmd.Dir, cmd.Env = wt, env
162		out, err := cmd.CombinedOutput()
163		require.NoError(t, err, string(out))
164	}
165	run("init", "-q", "--initial-branch=dev")
166	require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte("x"), 0o644))
167	run("add", "f")
168	run("commit", "-q", "-m", "one")
169	run("push", "-q", dir, "dev")
170
171	_, exists, err = g.DefaultBranch(ctx, n)
172	require.NoError(t, err)
173	assert.False(t, exists, "HEAD still points to main")
174
175	require.NoError(t, g.SetDefaultBranch(ctx, n, "dev"))
176	branch, exists, err = g.DefaultBranch(ctx, n)
177	require.NoError(t, err)
178	assert.Equal(t, "dev", branch)
179	assert.True(t, exists)
180	assert.Equal(t, "refs/heads/dev", gitOut(t, dir, "symbolic-ref", "HEAD"))
181
182	for _, bad := range []string{"", "-x", "a b", "a..b"} {
183		assert.Error(t, g.SetDefaultBranch(ctx, n, bad), bad)
184	}
185	assert.ErrorIs(t, g.SetDefaultBranch(ctx, name(t, "ghost"), "dev"), domain.ErrNotFound)
186
187	require.NoError(t, g.Maintain(ctx, n))
188	assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
189	assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
190}
191
192// bloomVersions returns the Bloom filter hash version (BDAT chunk) of every
193// layer of the commit-graph chain of the repository.
194func bloomVersions(t *testing.T, dir string) []uint32 {
195	t.Helper()
196	files, err := filepath.Glob(filepath.Join(dir, "objects", "info", "commit-graphs", "graph-*.graph"))
197	require.NoError(t, err)
198	require.NotEmpty(t, files)
199	var out []uint32
200	for _, f := range files {
201		d, err := os.ReadFile(f)
202		require.NoError(t, err)
203		chunks := int(d[6])
204		found := false
205		for i := range chunks {
206			row := 8 + 12*i
207			if string(d[row:row+4]) == "BDAT" {
208				off := binary.BigEndian.Uint64(d[row+4 : row+12])
209				out = append(out, binary.BigEndian.Uint32(d[off:off+4]))
210				found = true
211			}
212		}
213		require.True(t, found, "%s has no Bloom filters", f)
214	}
215	return out
216}
217
218// New repositories write v2 Bloom filters (no signed/unsigned char ambiguity),
219// and Maintain keeps writing v2: on top of a v2 chain, and over a v1 chain that
220// an older ermite or a plain git left behind.
221func TestMaintainWritesBloomFiltersV2(t *testing.T) {
222	ctx := context.Background()
223	g, data := newGit(t)
224	n := name(t, "bloom")
225	id, err := g.Stage(ctx)
226	require.NoError(t, err)
227	require.NoError(t, g.Publish(ctx, id, n))
228	dir := filepath.Join(data, "repos", "bloom.git")
229
230	wt := t.TempDir()
231	env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
232		"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
233	run := func(dir string, args ...string) {
234		cmd := exec.Command("git", args...)
235		cmd.Dir, cmd.Env = dir, env
236		out, err := cmd.CombinedOutput()
237		require.NoError(t, err, string(out))
238	}
239	run(wt, "init", "-q", "--initial-branch=main")
240	commit := func(i int) {
241		require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte(strings.Repeat("x", i+1)), 0o644))
242		run(wt, "add", "f")
243		run(wt, "commit", "-q", "-m", "c")
244		run(wt, "push", "-q", dir, "main")
245	}
246
247	commit(0)
248	commit(1)
249	require.NoError(t, g.Maintain(ctx, n))
250	assert.Equal(t, []uint32{2}, bloomVersions(t, dir))
251
252	// A v1 chain written by a git that was not configured for v2.
253	run(dir, "-c", "commitGraph.changedPathsVersion=1", "commit-graph", "write", "--reachable", "--changed-paths", "--split=replace")
254	require.Equal(t, []uint32{1}, bloomVersions(t, dir))
255	commit(2)
256	require.NoError(t, g.Maintain(ctx, n))
257	for _, v := range bloomVersions(t, dir) {
258		assert.Equal(t, uint32(2), v, "Maintain does not leave v1 filters in the chain")
259	}
260	commit(3)
261	require.NoError(t, g.Maintain(ctx, n))
262	for _, v := range bloomVersions(t, dir) {
263		assert.Equal(t, uint32(2), v)
264	}
265	run(dir, "commit-graph", "verify")
266}

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