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 }, backendConf(e))
90
91 push, err := g.HTTPBackend(ctx, true)
92 require.NoError(t, err)
93 pe := envMap(push.Env)
94 assert.Equal(t, "owner", pe["REMOTE_USER"])
95 assert.Equal(t, map[string]string{
96 "core.hooksPath": filepath.Join(data, "run", "hooks"),
97 "receive.fsckObjects": "true",
98 "uploadpack.allowFilter": "true",
99 "http.receivepack": "true",
100 }, backendConf(pe), "http.receivepack only for a push; no size limit unless configured")
101
102 // Nothing from the caller's environment leaks.
103 t.Setenv("GIT_DIR", "/leak")
104 again, err := g.HTTPBackend(ctx, true)
105 require.NoError(t, err)
106 assert.NotContains(t, envMap(again.Env), "GIT_DIR")
107
108 // The slice is the caller's: appending does not corrupt the next call.
109 _ = append(read.Env, "X=1")
110 third, err := g.HTTPBackend(ctx, false)
111 require.NoError(t, err)
112 assert.NotContains(t, envMap(third.Env), "X")
113}
114
115func TestHTTPBackendMissing(t *testing.T) {
116 data := t.TempDir()
117 script := writeScript(t, `case "$1" in --exec-path) echo "`+data+`/nowhere";; *) echo "git version 2.50.0";; esac`)
118 g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
119 _, err := g.HTTPBackend(context.Background(), false)
120 require.Error(t, err)
121 assert.Contains(t, err.Error(), "git-http-backend")
122
123 // A non-executable file is refused too.
124 require.NoError(t, os.MkdirAll(filepath.Join(data, "exec"), 0o755))
125 require.NoError(t, os.WriteFile(filepath.Join(data, "exec", "git-http-backend"), []byte("x"), 0o644))
126 script = writeScript(t, `echo "`+data+`/exec"`)
127 g = gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
128 _, err = g.HTTPBackend(context.Background(), false)
129 require.Error(t, err)
130 assert.Contains(t, err.Error(), "not an executable")
131}
132
133func TestDefaultBranchAndMaintain(t *testing.T) {
134 ctx := context.Background()
135 g, data := newGit(t)
136 n := name(t, "tools")
137 id, err := g.Stage(ctx)
138 require.NoError(t, err)
139 require.NoError(t, g.Publish(ctx, id, n))
140 dir := filepath.Join(data, "repos", "tools.git")
141
142 branch, exists, err := g.DefaultBranch(ctx, n)
143 require.NoError(t, err)
144 assert.Equal(t, "main", branch)
145 assert.False(t, exists, "no commit yet")
146
147 _, _, err = g.DefaultBranch(ctx, name(t, "ghost"))
148 assert.ErrorIs(t, err, domain.ErrNotFound)
149
150 // Put a commit on "dev" through a work tree.
151 wt := t.TempDir()
152 env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
153 "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
154 run := func(args ...string) {
155 cmd := exec.Command("git", args...)
156 cmd.Dir, cmd.Env = wt, env
157 out, err := cmd.CombinedOutput()
158 require.NoError(t, err, string(out))
159 }
160 run("init", "-q", "--initial-branch=dev")
161 require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte("x"), 0o644))
162 run("add", "f")
163 run("commit", "-q", "-m", "one")
164 run("push", "-q", dir, "dev")
165
166 _, exists, err = g.DefaultBranch(ctx, n)
167 require.NoError(t, err)
168 assert.False(t, exists, "HEAD still points to main")
169
170 require.NoError(t, g.SetDefaultBranch(ctx, n, "dev"))
171 branch, exists, err = g.DefaultBranch(ctx, n)
172 require.NoError(t, err)
173 assert.Equal(t, "dev", branch)
174 assert.True(t, exists)
175 assert.Equal(t, "refs/heads/dev", gitOut(t, dir, "symbolic-ref", "HEAD"))
176
177 for _, bad := range []string{"", "-x", "a b", "a..b"} {
178 assert.Error(t, g.SetDefaultBranch(ctx, n, bad), bad)
179 }
180 assert.ErrorIs(t, g.SetDefaultBranch(ctx, name(t, "ghost"), "dev"), domain.ErrNotFound)
181
182 require.NoError(t, g.Maintain(ctx, n))
183 assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
184 assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
185}
186
187// bloomVersions returns the Bloom filter hash version (BDAT chunk) of every
188// layer of the commit-graph chain of the repository.
189func bloomVersions(t *testing.T, dir string) []uint32 {
190 t.Helper()
191 files, err := filepath.Glob(filepath.Join(dir, "objects", "info", "commit-graphs", "graph-*.graph"))
192 require.NoError(t, err)
193 require.NotEmpty(t, files)
194 var out []uint32
195 for _, f := range files {
196 d, err := os.ReadFile(f)
197 require.NoError(t, err)
198 chunks := int(d[6])
199 found := false
200 for i := range chunks {
201 row := 8 + 12*i
202 if string(d[row:row+4]) == "BDAT" {
203 off := binary.BigEndian.Uint64(d[row+4 : row+12])
204 out = append(out, binary.BigEndian.Uint32(d[off:off+4]))
205 found = true
206 }
207 }
208 require.True(t, found, "%s has no Bloom filters", f)
209 }
210 return out
211}
212
213// New repositories write v2 Bloom filters (no signed/unsigned char ambiguity),
214// and Maintain keeps writing v2: on top of a v2 chain, and over a v1 chain that
215// an older ermite or a plain git left behind.
216func TestMaintainWritesBloomFiltersV2(t *testing.T) {
217 ctx := context.Background()
218 g, data := newGit(t)
219 n := name(t, "bloom")
220 id, err := g.Stage(ctx)
221 require.NoError(t, err)
222 require.NoError(t, g.Publish(ctx, id, n))
223 dir := filepath.Join(data, "repos", "bloom.git")
224
225 wt := t.TempDir()
226 env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
227 "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
228 run := func(dir string, args ...string) {
229 cmd := exec.Command("git", args...)
230 cmd.Dir, cmd.Env = dir, env
231 out, err := cmd.CombinedOutput()
232 require.NoError(t, err, string(out))
233 }
234 run(wt, "init", "-q", "--initial-branch=main")
235 commit := func(i int) {
236 require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte(strings.Repeat("x", i+1)), 0o644))
237 run(wt, "add", "f")
238 run(wt, "commit", "-q", "-m", "c")
239 run(wt, "push", "-q", dir, "main")
240 }
241
242 commit(0)
243 commit(1)
244 require.NoError(t, g.Maintain(ctx, n))
245 assert.Equal(t, []uint32{2}, bloomVersions(t, dir))
246
247 // A v1 chain written by a git that was not configured for v2.
248 run(dir, "-c", "commitGraph.changedPathsVersion=1", "commit-graph", "write", "--reachable", "--changed-paths", "--split=replace")
249 require.Equal(t, []uint32{1}, bloomVersions(t, dir))
250 commit(2)
251 require.NoError(t, g.Maintain(ctx, n))
252 for _, v := range bloomVersions(t, dir) {
253 assert.Equal(t, uint32(2), v, "Maintain does not leave v1 filters in the chain")
254 }
255 commit(3)
256 require.NoError(t, g.Maintain(ctx, n))
257 for _, v := range bloomVersions(t, dir) {
258 assert.Equal(t, uint32(2), v)
259 }
260 run(dir, "commit-graph", "verify")
261}