gitcmd_test.go
⎇
Raw
1package gitcmd
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/klauspost/compress/zstd"
14
15 "hearthforge/internal/config"
16)
17
18// newTestGit builds a bare repo with one commit and returns a Git for it.
19func newTestGit(t *testing.T) (*Git, string) {
20 t.Helper()
21 if _, err := exec.LookPath("git"); err != nil {
22 t.Skip("git not installed")
23 }
24 dir := t.TempDir()
25 cfg := &config.Config{
26 DataDir: dir,
27 BaseURL: "http://localhost:3000",
28 MaxConcurrentArchives: 2,
29 SSHHostKeyPath: filepath.Join(dir, "ssh_host_key"),
30 }
31 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
32 t.Fatal(err)
33 }
34
35 work := filepath.Join(dir, "work")
36 if err := os.MkdirAll(work, 0o755); err != nil {
37 t.Fatal(err)
38 }
39 run := func(cwd string, args ...string) {
40 t.Helper()
41 cmd := exec.Command("git", args...)
42 cmd.Dir = cwd
43 cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
44 if out, err := cmd.CombinedOutput(); err != nil {
45 t.Fatalf("git %v: %v\n%s", args, err, out)
46 }
47 }
48 run(work, "init", "-q", "-b", "main")
49 run(work, "config", "user.email", "t@example.com")
50 run(work, "config", "user.name", "Test")
51 if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hello archive\n"), 0o644); err != nil {
52 t.Fatal(err)
53 }
54 run(work, "add", "-A")
55 run(work, "commit", "-q", "-m", "init")
56 run(dir, "clone", "-q", "--bare", work, filepath.Join(cfg.ReposDir(), "archivetest.git"))
57
58 return New(cfg), "archivetest"
59}
60
61func TestLsTreeShowLog(t *testing.T) {
62 g, repo := newTestGit(t)
63 ctx := context.Background()
64
65 entries, err := g.LsTree(ctx, repo, "main", "")
66 if err != nil {
67 t.Fatal(err)
68 }
69 if len(entries) != 1 || entries[0].Name != "hello.txt" || entries[0].Type != "blob" {
70 t.Fatalf("unexpected tree: %+v", entries)
71 }
72
73 blob, err := g.Show(ctx, repo, "main", "hello.txt")
74 if err != nil {
75 t.Fatal(err)
76 }
77 if string(blob) != "hello archive\n" {
78 t.Fatalf("blob = %q", blob)
79 }
80 if _, err := g.Show(ctx, repo, "main", "missing.txt"); err == nil {
81 t.Fatal("expected error for missing path")
82 }
83
84 commits, err := g.Log(ctx, repo, "main", 10, 0)
85 if err != nil {
86 t.Fatal(err)
87 }
88 if len(commits) != 1 || commits[0].Subject != "init" || commits[0].Author != "Test" {
89 t.Fatalf("unexpected log: %+v", commits)
90 }
91 if commits[0].SigStatus != SigNone {
92 t.Fatalf("sig = %q", commits[0].SigStatus)
93 }
94}
95
96func TestArchiveAllFormats(t *testing.T) {
97 g, repo := newTestGit(t)
98 out := t.TempDir()
99 if err := g.Archive(context.Background(), repo, "main", "slug", out); err != nil {
100 t.Fatal(err)
101 }
102
103 for _, ext := range []string{".zip", ".tar.gz", ".tar.zst"} {
104 st, err := os.Stat(filepath.Join(out, "slug-main"+ext))
105 if err != nil {
106 t.Fatalf("%s: %v", ext, err)
107 }
108 if st.Size() == 0 {
109 t.Fatalf("%s is empty", ext)
110 }
111 }
112
113 compressed, err := os.ReadFile(filepath.Join(out, "slug-main.tar.zst"))
114 if err != nil {
115 t.Fatal(err)
116 }
117 if !bytes.HasPrefix(compressed, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
118 t.Fatal("missing zstd magic number")
119 }
120 dec, err := zstd.NewReader(bytes.NewReader(compressed))
121 if err != nil {
122 t.Fatal(err)
123 }
124 defer dec.Close()
125 tar, err := dec.DecodeAll(compressed, nil)
126 if err != nil {
127 t.Fatal(err)
128 }
129 // tar stores names as plain text in each 512-byte header block.
130 if !bytes.Contains(tar, []byte("hello.txt")) {
131 t.Fatal("tar does not contain hello.txt")
132 }
133 if len(tar)%512 != 0 {
134 t.Fatalf("tar length %d is not a multiple of 512", len(tar))
135 }
136}
137
138func TestArchiveBadRef(t *testing.T) {
139 g, repo := newTestGit(t)
140 out := t.TempDir()
141 if err := g.Archive(context.Background(), repo, "no-such-ref", "slug", out); err == nil {
142 t.Fatal("expected error for unknown ref")
143 }
144 if _, err := os.Stat(filepath.Join(out, "slug-no-such-ref.tar.zst")); err == nil {
145 t.Fatal("partial zst artifact left behind")
146 }
147}
148
149func TestNameAndRefValidation(t *testing.T) {
150 for _, bad := range []string{"", "..", "a/b", "../etc", "a b", "re$po"} {
151 if ValidRepoName(bad) {
152 t.Errorf("repo name %q should be rejected", bad)
153 }
154 }
155 for _, good := range []string{"repo", "my.repo", "my-repo_1"} {
156 if !ValidRepoName(good) {
157 t.Errorf("repo name %q should be accepted", good)
158 }
159 }
160 for _, bad := range []string{"", "--output=/tmp/x", "-rf", "a..b", "a b", "a:b", "a\\b"} {
161 if ValidRef(bad) {
162 t.Errorf("ref %q should be rejected", bad)
163 }
164 }
165 for _, good := range []string{"main", "refs/heads/main", "v1.0", "abc123"} {
166 if !ValidRef(good) {
167 t.Errorf("ref %q should be accepted", good)
168 }
169 }
170 for _, bad := range []string{"", "/etc/passwd", "-x", "a/../b"} {
171 if ValidPath(bad) {
172 t.Errorf("path %q should be rejected", bad)
173 }
174 }
175
176 g, repo := newTestGit(t)
177 if _, err := g.Log(context.Background(), repo, "--output=/tmp/pwn", 1, 0); err == nil {
178 t.Fatal("expected invalid ref error")
179 }
180 if _, err := g.LsTree(context.Background(), "../escape", "main", ""); err == nil {
181 t.Fatal("expected invalid repo name error")
182 }
183}
184
185func TestWriteOpsAndSigning(t *testing.T) {
186 if _, err := exec.LookPath("ssh-keygen"); err != nil {
187 t.Skip("ssh-keygen not installed")
188 }
189 g, repo := newTestGit(t)
190 ctx := context.Background()
191 if err := g.EnsureSigningSetup(); err != nil {
192 t.Fatal(err)
193 }
194 who := Ident{Name: "Bot", Email: "bot@example.com"}
195
196 sha, err := g.EditFile(ctx, repo, "main", "", "docs/new.txt", []byte("new\n"), "add file", who)
197 if err != nil {
198 t.Fatal(err)
199 }
200 commits, err := g.Log(ctx, repo, "main", 1, 0)
201 if err != nil {
202 t.Fatal(err)
203 }
204 if commits[0].Hash != sha || commits[0].SigStatus != SigGood {
205 t.Fatalf("expected signed commit %s, got %+v", sha, commits[0])
206 }
207
208 if _, err := g.MoveFile(ctx, repo, "main", "docs/new.txt", "docs/moved.txt", "move", who); err != nil {
209 t.Fatal(err)
210 }
211 entries, err := g.LsTree(ctx, repo, "main", "docs")
212 if err != nil {
213 t.Fatal(err)
214 }
215 if len(entries) != 1 || entries[0].Name != "moved.txt" {
216 t.Fatalf("after move: %+v", entries)
217 }
218
219 if _, err := g.DeleteFile(ctx, repo, "main", "docs/moved.txt", "delete", who); err != nil {
220 t.Fatal(err)
221 }
222 if _, err := g.Show(ctx, repo, "main", "docs/moved.txt"); err == nil {
223 t.Fatal("deleted file still present")
224 }
225
226 if err := g.CreateBranch(ctx, repo, "feature", "main"); err != nil {
227 t.Fatal(err)
228 }
229 if err := g.CreateBranch(ctx, repo, "feature", "main"); !errors.Is(err, ErrExists) {
230 t.Fatalf("want ErrExists, got %v", err)
231 }
232 if err := g.CreateBranch(ctx, repo, "other", "nope"); !errors.Is(err, ErrBadRef) {
233 t.Fatalf("want ErrBadRef, got %v", err)
234 }
235 if err := g.RenameBranch(ctx, repo, "feature", "feature2"); err != nil {
236 t.Fatal(err)
237 }
238 if err := g.DeleteBranch(ctx, repo, "feature2"); err != nil {
239 t.Fatal(err)
240 }
241 if err := g.DeleteBranch(ctx, repo, "feature2"); !errors.Is(err, ErrNotFound) {
242 t.Fatalf("want ErrNotFound, got %v", err)
243 }
244
245 if err := g.CreateTag(ctx, repo, "v1.0", "main", "release one", who); err != nil {
246 t.Fatal(err)
247 }
248 tags, err := g.TagsWithInfo(ctx, repo, 0)
249 if err != nil {
250 t.Fatal(err)
251 }
252 if len(tags) != 1 || tags[0].Name != "v1.0" || !tags[0].IsAnnotated {
253 t.Fatalf("tags: %+v", tags)
254 }
255 if err := g.CreateTag(ctx, repo, "v1.0", "main", "", who); !errors.Is(err, ErrExists) {
256 t.Fatalf("want ErrExists, got %v", err)
257 }
258}
259
260func TestPatchApply(t *testing.T) {
261 if _, err := exec.LookPath("ssh-keygen"); err != nil {
262 t.Skip("ssh-keygen not installed")
263 }
264 g, repo := newTestGit(t)
265 ctx := context.Background()
266 if err := g.EnsureSigningSetup(); err != nil {
267 t.Fatal(err)
268 }
269 patch := "Subject: [PATCH] change greeting\n\n" +
270 "---\n" +
271 "diff --git a/hello.txt b/hello.txt\n" +
272 "--- a/hello.txt\n" +
273 "+++ b/hello.txt\n" +
274 "@@ -1 +1 @@\n" +
275 "-hello archive\n" +
276 "+goodbye archive\n"
277
278 res, err := g.CheckPatch(ctx, repo, patch)
279 if err != nil || res.Status != "clean" {
280 t.Fatalf("check: %v %+v", err, res)
281 }
282 if res, _ := g.CheckPatch(ctx, repo, strings.Replace(patch, "-hello archive", "-nope", 1)); res.Status != "conflict" {
283 t.Fatalf("expected conflict, got %+v", res)
284 }
285 if _, err := g.ApplyPatch(ctx, repo, patch, Ident{"A", "a@x"}, Ident{"C", "c@x"}); err != nil {
286 t.Fatal(err)
287 }
288 blob, err := g.Show(ctx, repo, "HEAD", "hello.txt")
289 if err != nil {
290 t.Fatal(err)
291 }
292 if string(blob) != "goodbye archive\n" {
293 t.Fatalf("blob = %q", blob)
294 }
295 if meta := ExtractPatchMeta(patch); meta.Subject != "change greeting" {
296 t.Fatalf("meta = %+v", meta)
297 }
298}
299
300func TestPatchCommitMessage(t *testing.T) {
301 patch := "From abc Mon Sep 17 00:00:00 2001\n" +
302 "From: Ada <ada@example.com>\n" +
303 "Date: Thu, 1 Jan 2026 00:00:00 +0000\n" +
304 "Subject: [PATCH 1/2] Fix the widget\n" +
305 "\n" +
306 "The widget was off by one.\n" +
307 "\n" +
308 "Signed-off-by: Ada <ada@example.com>\n" +
309 "---\n" +
310 " a.txt | 1 +\n"
311 want := "Fix the widget\n\nThe widget was off by one.\n\nSigned-off-by: Ada <ada@example.com>"
312 if got := PatchCommitMessage(patch); got != want {
313 t.Fatalf("got %q want %q", got, want)
314 }
315 // A patch with no body keeps just the subject.
316 if got := PatchCommitMessage("Subject: Only a subject\n\n---\n"); got != "Only a subject" {
317 t.Fatalf("got %q", got)
318 }
319}
320
321// git format-patch folds a long subject over several lines and encodes
322// non-ASCII names as RFC 2047 words.
323func TestExtractPatchMetaFoldedAndEncodedHeaders(t *testing.T) {
324 patch := "From abc Mon Sep 17 00:00:00 2001\n" +
325 "From: =?UTF-8?q?J=C3=B6rg=20M=C3=BCller?= <j@x.de>\n" +
326 "Date: Thu, 1 Jan 2026 00:00:00 +0000\n" +
327 "Subject: [PATCH] Make the widget stop counting\n" +
328 " from the wrong end\n" +
329 "\n" +
330 "Body line.\n" +
331 "---\n" +
332 " a.txt | 1 +\n"
333 m := ExtractPatchMeta(patch)
334 if m.Author != "Jörg Müller" || m.Email != "j@x.de" {
335 t.Fatalf("author = %q <%q>", m.Author, m.Email)
336 }
337 if m.Subject != "Make the widget stop counting from the wrong end" {
338 t.Fatalf("subject = %q", m.Subject)
339 }
340 if m.Body != "Body line." {
341 t.Fatalf("body = %q", m.Body)
342 }
343}
344
345// An encoded subject split over two lines joins without the folding space.
346func TestExtractPatchMetaFoldedEncodedSubject(t *testing.T) {
347 patch := "Subject: =?UTF-8?q?Gr=C3=BC=C3=9F?=\n =?UTF-8?q?e?=\n\nbody\n---\n"
348 if got := ExtractPatchMeta(patch).Subject; got != "Grüße" {
349 t.Fatalf("subject = %q", got)
350 }
351}
352