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, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
197
198 n := name(t, "tools")
199 require.NoError(t, g.Publish(ctx, id, n))
200 assert.NoDirExists(t, staged)
201 assert.Equal(t, "true", gitOut(t, filepath.Join(data, "repos", "tools.git"), "rev-parse", "--is-bare-repository"))
202
203 // A second staged repo cannot take the same name and is left for Discard.
204 id2, err := g.Stage(ctx)
205 require.NoError(t, err)
206 err = g.Publish(ctx, id2, n)
207 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
208 assert.DirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
209 require.NoError(t, g.Discard(ctx, id2))
210 assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", string(id2)))
211 require.NoError(t, g.Discard(ctx, id2), "discarding twice is not an error")
212}
213
214func TestPublishOverEmptyDirectoryDoesNotReplace(t *testing.T) {
215 ctx := context.Background()
216 g, data := newGit(t)
217 require.NoError(t, os.Mkdir(filepath.Join(data, "repos", "taken.git"), 0o700))
218 id, err := g.Stage(ctx)
219 require.NoError(t, err)
220 assert.ErrorIs(t, g.Publish(ctx, id, name(t, "taken")), domain.ErrAlreadyExists)
221}
222
223func TestInvalidStagedID(t *testing.T) {
224 ctx := context.Background()
225 g, data := newGit(t)
226 for _, id := range []ports.StagedID{"", "../../trash", "zz", ports.StagedID(strings.Repeat("g", 32))} {
227 assert.Error(t, g.Discard(ctx, id), string(id))
228 assert.Error(t, g.Publish(ctx, id, name(t, "x")), string(id))
229 }
230 assert.DirExists(t, filepath.Join(data, "trash"))
231}
232
233func TestConcurrentPublishSameName(t *testing.T) {
234 ctx := context.Background()
235 g, data := newGit(t)
236 n := name(t, "race")
237
238 const workers = 6
239 ids := make([]ports.StagedID, workers)
240 for i := range ids {
241 id, err := g.Stage(ctx)
242 require.NoError(t, err)
243 ids[i] = id
244 }
245 errs := make(chan error, workers)
246 var wg sync.WaitGroup
247 for _, id := range ids {
248 wg.Add(1)
249 go func() {
250 defer wg.Done()
251 errs <- g.Publish(ctx, id, n)
252 }()
253 }
254 wg.Wait()
255 close(errs)
256 ok, exists := 0, 0
257 for err := range errs {
258 switch {
259 case err == nil:
260 ok++
261 case errors.Is(err, domain.ErrAlreadyExists):
262 exists++
263 default:
264 t.Fatalf("unexpected: %v", err)
265 }
266 }
267 assert.Equal(t, 1, ok)
268 assert.Equal(t, workers-1, exists)
269 for _, id := range ids {
270 require.NoError(t, g.Discard(ctx, id))
271 }
272 entries, err := os.ReadDir(filepath.Join(data, "repos", ".staging"))
273 require.NoError(t, err)
274 assert.Empty(t, entries)
275}
276
277func TestStageFailureLeavesNothing(t *testing.T) {
278 ctx := context.Background()
279 data := t.TempDir()
280 require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging"), 0o700))
281 // A git that creates the directory and then fails.
282 script := writeScript(t, `for last; do :; done; mkdir -p "$last"; echo nope >&2; exit 1`)
283 g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: os.Getenv("PATH")})
284 _, err := g.Stage(ctx)
285 require.Error(t, err)
286 entries, rerr := os.ReadDir(filepath.Join(data, "repos", ".staging"))
287 require.NoError(t, rerr)
288 assert.Empty(t, entries)
289}
290
291func TestPurgeStagingAndList(t *testing.T) {
292 ctx := context.Background()
293 g, data := newGit(t)
294 repos := filepath.Join(data, "repos")
295
296 _, err := g.Stage(ctx)
297 require.NoError(t, err)
298 require.NoError(t, os.WriteFile(filepath.Join(repos, ".staging", "stray"), []byte("x"), 0o600))
299 for _, d := range []string{"good.git", "also-good.git", "Bad.git", "no-suffix", ".hidden.git", "x..y.git"} {
300 require.NoError(t, os.Mkdir(filepath.Join(repos, d), 0o700))
301 }
302 require.NoError(t, os.WriteFile(filepath.Join(repos, "file.git"), []byte("x"), 0o600))
303
304 require.NoError(t, g.PurgeStaging(ctx))
305 entries, err := os.ReadDir(filepath.Join(repos, ".staging"))
306 require.NoError(t, err)
307 assert.Empty(t, entries)
308 assert.DirExists(t, filepath.Join(repos, ".staging"))
309
310 dirs, err := g.List(ctx)
311 require.NoError(t, err)
312 assert.ElementsMatch(t, []domain.RepoName{name(t, "good"), name(t, "also-good")}, dirs.Names)
313 assert.ElementsMatch(t, []string{"Bad.git", "no-suffix", ".hidden.git", "x..y.git", "file.git"}, dirs.Invalid)
314}
315
316func TestTrash(t *testing.T) {
317 ctx := context.Background()
318 g, data := newGit(t)
319 n := name(t, "old")
320
321 assert.ErrorIs(t, g.Trash(ctx, n), domain.ErrNotFound)
322
323 for range 2 {
324 id, err := g.Stage(ctx)
325 require.NoError(t, err)
326 require.NoError(t, g.Publish(ctx, id, n))
327 require.NoError(t, g.Trash(ctx, n))
328 }
329 assert.NoDirExists(t, filepath.Join(data, "repos", "old.git"))
330 entries, err := os.ReadDir(filepath.Join(data, "trash"))
331 require.NoError(t, err)
332 require.Len(t, entries, 2, "two trashings never overwrite each other")
333 for _, e := range entries {
334 assert.True(t, strings.HasPrefix(e.Name(), "old-") && strings.HasSuffix(e.Name(), ".git"), e.Name())
335 }
336}