v0.28.1 cc7ba64

gitcli_test.go RawHistoryFolder

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}

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