1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "fmt"
7 "math/rand/v2"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18)
19
20// fixtureRepo builds a repository with real git: a bare repository in the
21// data directory, and a work tree beside it that the helpers edit and commit.
22type fixtureRepo struct {
23 t *testing.T
24 name domain.RepoName
25 bare string
26 work string
27 when time.Time
28}
29
30// newFixtureRepo creates an empty bare repository repos/<name>.git whose HEAD
31// is refs/heads/main.
32func newFixtureRepo(t *testing.T, data, repo string) *fixtureRepo {
33 t.Helper()
34 f := &fixtureRepo{
35 t: t,
36 name: name(t, repo),
37 bare: filepath.Join(data, "repos", repo+".git"),
38 work: t.TempDir(),
39 when: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
40 }
41 out, err := exec.Command("git", "init", "--quiet", "--bare", "--initial-branch=main", f.bare).CombinedOutput()
42 require.NoError(t, err, string(out))
43 f.git("config", "core.compression", "0")
44 f.git("config", "core.autocrlf", "false")
45 return f
46}
47
48// git runs git against the bare repository and the work tree.
49func (f *fixtureRepo) git(args ...string) string {
50 f.t.Helper()
51 cmd := exec.Command("git", args...)
52 cmd.Dir = f.work
53 cmd.Env = []string{
54 "PATH=" + os.Getenv("PATH"),
55 "HOME=" + f.work,
56 "GIT_CONFIG_NOSYSTEM=1",
57 "GIT_CONFIG_GLOBAL=/dev/null",
58 "GIT_DIR=" + f.bare,
59 "GIT_WORK_TREE=" + f.work,
60 "GIT_AUTHOR_NAME=Ada Lovelace", "GIT_AUTHOR_EMAIL=ada@example.org",
61 "GIT_COMMITTER_NAME=Grace Hopper", "GIT_COMMITTER_EMAIL=grace@example.org",
62 "GIT_AUTHOR_DATE=" + f.when.Format(time.RFC3339),
63 "GIT_COMMITTER_DATE=" + f.when.Format(time.RFC3339),
64 }
65 out, err := cmd.CombinedOutput()
66 require.NoError(f.t, err, "git %s: %s", strings.Join(args, " "), out)
67 return strings.TrimSpace(string(out))
68}
69
70func (f *fixtureRepo) path(p string) string { return filepath.Join(f.work, filepath.FromSlash(p)) }
71
72// write creates or replaces a file with the content.
73func (f *fixtureRepo) write(p, content string) *fixtureRepo {
74 f.t.Helper()
75 require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
76 require.NoError(f.t, os.WriteFile(f.path(p), []byte(content), 0o644))
77 return f
78}
79
80// writeBytes is write for binary content.
81func (f *fixtureRepo) writeBytes(p string, content []byte) *fixtureRepo {
82 f.t.Helper()
83 require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(p)), 0o755))
84 require.NoError(f.t, os.WriteFile(f.path(p), content, 0o644))
85 return f
86}
87
88// lines writes n lines "<prefix> <i>" to the file.
89func (f *fixtureRepo) lines(p, prefix string, n int) *fixtureRepo {
90 var b strings.Builder
91 for i := range n {
92 fmt.Fprintf(&b, "%s %d\n", prefix, i)
93 }
94 return f.write(p, b.String())
95}
96
97func (f *fixtureRepo) remove(p string) *fixtureRepo {
98 f.t.Helper()
99 require.NoError(f.t, os.Remove(f.path(p)))
100 return f
101}
102
103func (f *fixtureRepo) chmod(p string, mode os.FileMode) *fixtureRepo {
104 f.t.Helper()
105 require.NoError(f.t, os.Chmod(f.path(p), mode))
106 return f
107}
108
109func (f *fixtureRepo) symlink(target, p string) *fixtureRepo {
110 f.t.Helper()
111 require.NoError(f.t, os.Symlink(target, f.path(p)))
112 return f
113}
114
115func (f *fixtureRepo) mv(from, to string) *fixtureRepo {
116 f.t.Helper()
117 require.NoError(f.t, os.MkdirAll(filepath.Dir(f.path(to)), 0o755))
118 require.NoError(f.t, os.Rename(f.path(from), f.path(to)))
119 return f
120}
121
122// commit stages everything and commits with the next timestamp; it returns
123// the commit id.
124func (f *fixtureRepo) commit(msg string) string {
125 f.t.Helper()
126 f.when = f.when.Add(time.Minute)
127 f.git("add", "-A")
128 f.git("commit", "--quiet", "--allow-empty", "-m", msg)
129 return f.git("rev-parse", "HEAD")
130}
131
132// checkoutNew creates a branch from the current HEAD and switches to it.
133func (f *fixtureRepo) checkoutNew(branch string) {
134 f.t.Helper()
135 f.git("checkout", "--quiet", "-b", branch)
136}
137
138func (f *fixtureRepo) checkout(branch string) { f.t.Helper(); f.git("checkout", "--quiet", branch) }
139
140// merge merges the branch with a merge commit.
141func (f *fixtureRepo) merge(branch string) string {
142 f.t.Helper()
143 f.when = f.when.Add(time.Minute)
144 f.git("merge", "--quiet", "--no-ff", "-m", "merge "+branch, branch)
145 return f.git("rev-parse", "HEAD")
146}
147
148// randomBytes returns n deterministic incompressible bytes.
149func randomBytes(n int, seed uint64) []byte {
150 var s [32]byte
151 for i := range 8 {
152 s[i] = byte(seed >> (8 * i))
153 }
154 r := rand.NewChaCha8(s)
155 b := make([]byte, n)
156 _, _ = r.Read(b)
157 return b
158}