v0.1.0 3f267b5

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, "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}

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