1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "errors"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "sync"
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 "bnema.dev/ermite/internal/ports"
21)
22
23func TestParseVersion(t *testing.T) {
24 tests := []struct {
25 in string
26 want gitcli.Version
27 wantErr bool
28 }{
29 {in: "git version 2.45.0\n", want: gitcli.Version{Major: 2, Minor: 45}},
30 {in: "git version 2.56.0", want: gitcli.Version{Major: 2, Minor: 56}},
31 {in: "git version 2.39.5 (Apple Git-154)\n", want: gitcli.Version{Major: 2, Minor: 39, Patch: 5}},
32 {in: "git version 2.46.0.rc1\n", want: gitcli.Version{Major: 2, Minor: 46}},
33 {in: "git version 2.47.1.windows.1", want: gitcli.Version{Major: 2, Minor: 47, Patch: 1}},
34 {in: "git version 3.0", want: gitcli.Version{Major: 3}},
35 {in: "git version", wantErr: true},
36 {in: "", wantErr: true},
37 {in: "hg version 6.0", wantErr: true},
38 {in: "git version two.five", wantErr: true},
39 }
40 for _, tt := range tests {
41 t.Run(tt.in, func(t *testing.T) {
42 got, err := gitcli.ParseVersion(tt.in)
43 if tt.wantErr {
44 assert.Error(t, err)
45 return
46 }
47 require.NoError(t, err)
48 assert.Equal(t, tt.want, got)
49 })
50 }
51}
52
53func TestVersionLess(t *testing.T) {
54 min := gitcli.MinVersion
55 assert.True(t, gitcli.Version{Major: 2, Minor: 44, Patch: 9}.Less(min))
56 assert.True(t, gitcli.Version{Major: 1, Minor: 99}.Less(min))
57 assert.False(t, gitcli.Version{Major: 2, Minor: 45}.Less(min))
58 assert.False(t, gitcli.Version{Major: 2, Minor: 46}.Less(min))
59 assert.False(t, gitcli.Version{Major: 3}.Less(min))
60}
61
62// unusable skips the test, or fails it in CI so that CI cannot pass vacuously.
63func unusable(t *testing.T, format string, args ...any) {
64 t.Helper()
65 if os.Getenv("CI") != "" {
66 t.Fatalf(format, args...)
67 }
68 t.Skipf(format, args...)
69}
70
71// newGit prepares a data dir and an adapter. Without a usable git (2.45 or
72// newer) it skips locally and fails when CI is set.
73func newGit(t *testing.T) (*gitcli.Git, string) {
74 t.Helper()
75 bin, err := exec.LookPath("git")
76 if err != nil {
77 unusable(t, "git not found")
78 }
79 data := t.TempDir()
80 for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
81 require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
82 }
83 g := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")})
84 if _, err := g.CheckVersion(context.Background()); err != nil {
85 unusable(t, "git is not usable by ermite: %v", err)
86 }
87 return g, data
88}
89
90func name(t *testing.T, s string) domain.RepoName {
91 t.Helper()
92 n, err := domain.ParseRepoName(s)
93 require.NoError(t, err)
94 return n
95}
96
97func TestCheckVersion(t *testing.T) {
98 g, _ := newGit(t)
99 v, err := g.CheckVersion(context.Background())
100 require.NoError(t, err)
101 assert.False(t, v.Less(gitcli.MinVersion))
102}
103
104func writeScript(t *testing.T, body string) string {
105 t.Helper()
106 p := filepath.Join(t.TempDir(), "git")
107 require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o700))
108 return p
109}
110
111func TestCheckVersionRejects(t *testing.T) {
112 ctx := context.Background()
113 data := t.TempDir()
114 cfg := func(bin string) gitcli.Config { return gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")} }
115
116 t.Run("too old", func(t *testing.T) {
117 _, err := gitcli.New(cfg(writeScript(t, `echo "git version 2.44.9"`))).CheckVersion(ctx)
118 require.Error(t, err)
119 assert.Contains(t, err.Error(), "older than")
120 })
121 t.Run("garbage output", func(t *testing.T) {
122 _, err := gitcli.New(cfg(writeScript(t, `echo hello`))).CheckVersion(ctx)
123 assert.Error(t, err)
124 })
125 t.Run("missing binary", func(t *testing.T) {
126 _, err := gitcli.New(cfg(filepath.Join(data, "no-such-git"))).CheckVersion(ctx)
127 assert.Error(t, err)
128 })
129 t.Run("failing binary", func(t *testing.T) {
130 _, err := gitcli.New(cfg(writeScript(t, `echo broken >&2; exit 3`))).CheckVersion(ctx)
131 require.Error(t, err)
132 assert.Contains(t, err.Error(), "broken")
133 })
134}
135
136func TestIsolatedEnvironment(t *testing.T) {
137 ctx := context.Background()
138 data := t.TempDir()
139 out := filepath.Join(data, "env.txt")
140 script := writeScript(t, `env > "`+out+`"; echo "git version 2.50.0"`)
141 // Variables that must not reach git.
142 t.Setenv("GIT_DIR", "/nonexistent")
143 t.Setenv("GIT_AUTHOR_NAME", "leak")
144 t.Setenv("GIT_CONFIG_GLOBAL", "/leak")
145 t.Setenv("XDG_CONFIG_HOME", "/leak")
146 t.Setenv("SECRET_TOKEN", "leak")
147
148 g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: os.Getenv("PATH")})
149 _, err := g.CheckVersion(ctx)
150 require.NoError(t, err)
151
152 raw, err := os.ReadFile(out)
153 require.NoError(t, err)
154 got := map[string]string{}
155 for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
156 k, v, _ := strings.Cut(l, "=")
157 got[k] = v
158 }
159 assert.Equal(t, "1", got["GIT_CONFIG_NOSYSTEM"])
160 assert.Equal(t, filepath.Join(data, "run", "git-home"), got["HOME"])
161 assert.Equal(t, "0", got["GIT_TERMINAL_PROMPT"])
162 assert.Equal(t, os.Getenv("PATH"), got["PATH"])
163 assert.Equal(t, "C", got["LC_ALL"])
164 for k, v := range got {
165 assert.NotContains(t, v, "leak", k)
166 switch k {
167 case "GIT_DIR", "GIT_AUTHOR_NAME", "GIT_CONFIG_GLOBAL", "XDG_CONFIG_HOME", "SECRET_TOKEN":
168 t.Errorf("variable %s leaked into git", k)
169 }
170 }
171}
172
173func gitOut(t *testing.T, dir string, args ...string) string {
174 t.Helper()
175 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
176 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1"}
177 out, err := cmd.CombinedOutput()
178 require.NoError(t, err, string(out))
179 return strings.TrimSpace(string(out))
180}
181
182func TestStagePublish(t *testing.T) {
183 ctx := context.Background()
184 g, data := newGit(t)
185
186 id, err := g.Stage(ctx)
187 require.NoError(t, err)
188 staged := filepath.Join(data, "repos", ".staging", string(id))
189 require.DirExists(t, staged)
190
191 assert.Equal(t, "true", gitOut(t, staged, "rev-parse", "--is-bare-repository"))
192 assert.Equal(t, "sha1", gitOut(t, staged, "rev-parse", "--show-object-format"))
193 assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))
194 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))
195 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))
196 assert.Equal(t, "2", gitOut(t, staged, "config", "--local", "commitGraph.changedPathsVersion"),
197 "v2 Bloom filters: no signed/unsigned char ambiguity")
198 assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
199
200 n := name(t, "tools")
201 require.NoError(t, g.Publish(ctx, id, n))
202 assert.NoDirExists(t, staged)
203 assert.Equal(t, "true", gitOut(t, filepath.Join(data, "repos", "tools.git"), "rev-parse", "--is-bare-repository"))
204
205 // A second staged repo cannot take the same name and is left for Discard.
206 id2, err := g.Stage(ctx)
207 require.NoError(t, err)
208 err = g.Publish(ctx, id2, n)
209 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
210 assert.DirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
211 require.NoError(t, g.Discard(ctx, id2))
212 assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
213 require.NoError(t, g.Discard(ctx, id2), "discarding twice is not an error")
214}
215
216func TestPublishOverEmptyDirectoryDoesNotReplace(t *testing.T) {
217 ctx := context.Background()
218 g, data := newGit(t)
219 require.NoError(t, os.Mkdir(filepath.Join(data, "repos", "taken.git"), 0o700))
220 id, err := g.Stage(ctx)
221 require.NoError(t, err)
222 assert.ErrorIs(t, g.Publish(ctx, id, name(t, "taken")), domain.ErrAlreadyExists)
223}
224
225func TestInvalidStagedID(t *testing.T) {
226 ctx := context.Background()
227 g, data := newGit(t)
228 for _, id := range []ports.StagedID{"", "../../trash", "zz", ports.StagedID(strings.Repeat("g", 32))} {
229 assert.Error(t, g.Discard(ctx, id), string(id))
230 assert.Error(t, g.Publish(ctx, id, name(t, "x")), string(id))
231 }
232 assert.DirExists(t, filepath.Join(data, "trash"))
233}
234
235func TestConcurrentPublishSameName(t *testing.T) {
236 ctx := context.Background()
237 g, data := newGit(t)
238 n := name(t, "race")
239
240 const workers = 6
241 ids := make([]ports.StagedID, workers)
242 for i := range ids {
243 id, err := g.Stage(ctx)
244 require.NoError(t, err)
245 ids[i] = id
246 }
247 errs := make(chan error, workers)
248 var wg sync.WaitGroup
249 for _, id := range ids {
250 wg.Add(1)
251 go func() {
252 defer wg.Done()
253 errs <- g.Publish(ctx, id, n)
254 }()
255 }
256 wg.Wait()
257 close(errs)
258 ok, exists := 0, 0
259 for err := range errs {
260 switch {
261 case err == nil:
262 ok++
263 case errors.Is(err, domain.ErrAlreadyExists):
264 exists++
265 default:
266 t.Fatalf("unexpected: %v", err)
267 }
268 }
269 assert.Equal(t, 1, ok)
270 assert.Equal(t, workers-1, exists)
271 for _, id := range ids {
272 require.NoError(t, g.Discard(ctx, id))
273 }
274 entries, err := os.ReadDir(filepath.Join(data, "repos", ".staging"))
275 require.NoError(t, err)
276 assert.Empty(t, entries)
277}
278
279func TestStageFailureLeavesNothing(t *testing.T) {
280 ctx := context.Background()
281 data := t.TempDir()
282 require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging"), 0o700))
283 // A git that creates the directory and then fails.
284 script := writeScript(t, `for last; do :; done; mkdir -p "$last"; echo nope >&2; exit 1`)
285 g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: os.Getenv("PATH")})
286 _, err := g.Stage(ctx)
287 require.Error(t, err)
288 entries, rerr := os.ReadDir(filepath.Join(data, "repos", ".staging"))
289 require.NoError(t, rerr)
290 assert.Empty(t, entries)
291}
292
293func TestPurgeStagingAndList(t *testing.T) {
294 ctx := context.Background()
295 g, data := newGit(t)
296 repos := filepath.Join(data, "repos")
297
298 _, err := g.Stage(ctx)
299 require.NoError(t, err)
300 require.NoError(t, os.WriteFile(filepath.Join(repos, ".staging", "stray"), []byte("x"), 0o600))
301 for _, d := range []string{"good.git", "also-good.git", "Bad.git", "no-suffix", ".hidden.git", "x..y.git"} {
302 require.NoError(t, os.Mkdir(filepath.Join(repos, d), 0o700))
303 }
304 require.NoError(t, os.WriteFile(filepath.Join(repos, "file.git"), []byte("x"), 0o600))
305
306 require.NoError(t, g.PurgeStaging(ctx))
307 entries, err := os.ReadDir(filepath.Join(repos, ".staging"))
308 require.NoError(t, err)
309 assert.Empty(t, entries)
310 assert.DirExists(t, filepath.Join(repos, ".staging"))
311
312 dirs, err := g.List(ctx)
313 require.NoError(t, err)
314 assert.ElementsMatch(t, []domain.RepoName{name(t, "good"), name(t, "also-good")}, dirs.Names)
315 assert.ElementsMatch(t, []string{"Bad.git", "no-suffix", ".hidden.git", "x..y.git", "file.git"}, dirs.Invalid)
316}
317
318func TestTrash(t *testing.T) {
319 ctx := context.Background()
320 g, data := newGit(t)
321 n := name(t, "old")
322
323 assert.ErrorIs(t, g.Trash(ctx, n), domain.ErrNotFound)
324
325 for range 2 {
326 id, err := g.Stage(ctx)
327 require.NoError(t, err)
328 require.NoError(t, g.Publish(ctx, id, n))
329 require.NoError(t, g.Trash(ctx, n))
330 }
331 assert.NoDirExists(t, filepath.Join(data, "repos", "old.git"))
332 entries, err := os.ReadDir(filepath.Join(data, "trash"))
333 require.NoError(t, err)
334 require.Len(t, entries, 2, "two trashings never overwrite each other")
335 for _, e := range entries {
336 assert.True(t, strings.HasPrefix(e.Name(), "old-") && strings.HasSuffix(e.Name(), ".git"), e.Name())
337 }
338}