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.EditFile(ctx, repo, "main", "", "docs/new.txt", "docs/moved.txt", []byte("new\n"), "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
353func TestParseSigStatus(t *testing.T) {
354 for code, want := range map[string]SigStatus{
355 "G": SigGood, "B": SigBad, "R": SigBad,
356 "U": SigUnverified, "X": SigUnverified, "Y": SigUnverified, "E": SigUnverified,
357 "N": SigNone, "": SigNone,
358 } {
359 if got := parseSigStatus(code); got != want {
360 t.Errorf("parseSigStatus(%q) = %q, want %q", code, got, want)
361 }
362 }
363}
364
365func TestLsTreeNonASCIIName(t *testing.T) {
366 g, repo := newSigningGit(t)
367 if _, err := g.EditFile(context.Background(), repo, "main", "", "", "dir/ä ö.txt", []byte("x\n"), "add", testWho); err != nil {
368 t.Fatal(err)
369 }
370 entries, err := g.LsTree(context.Background(), repo, "main", "dir")
371 if err != nil {
372 t.Fatal(err)
373 }
374 if len(entries) != 1 || entries[0].Name != "ä ö.txt" || entries[0].Size != "2" {
375 t.Fatalf("entries = %+v", entries)
376 }
377}
378
379func TestDiffLimitAndBlobSizes(t *testing.T) {
380 g, repo := newSigningGit(t)
381 ctx := context.Background()
382 sha, err := g.EditFile(ctx, repo, "main", "", "", "big.txt", []byte(strings.Repeat("line\n", 1000)), "add", testWho)
383 if err != nil {
384 t.Fatal(err)
385 }
386 if _, err := g.Diff(ctx, repo, sha, 1000); !errors.Is(err, ErrTooLarge) {
387 t.Fatalf("want ErrTooLarge, got %v", err)
388 }
389 out, err := g.Diff(ctx, repo, sha, 1<<20)
390 if err != nil || !strings.Contains(out, "+++ b/big.txt") {
391 t.Fatalf("diff = %q, %v", out, err)
392 }
393 hello, _ := g.run(ctx, runOpts{}, "-C", g.RepoPath(repo), "rev-parse", "main:hello.txt")
394 blob := strings.TrimSpace(string(hello))
395 sizes, err := g.BlobSizes(ctx, repo, []string{blob[:7], "deadbeef"})
396 if err != nil {
397 t.Fatal(err)
398 }
399 if len(sizes) != 1 || sizes[blob[:7]] != int64(len("hello archive\n")) {
400 t.Fatalf("sizes = %v", sizes)
401 }
402}
403
404func TestObjectFormat(t *testing.T) {
405 g, repo := newTestGit(t)
406 ctx := context.Background()
407
408 if err := g.Init(ctx, "empty", "trunk", "sha256"); err != nil {
409 t.Fatal(err)
410 }
411 if f, _ := g.ObjectFormat(ctx, "empty"); f != "sha256" {
412 t.Fatalf("format after init = %q", f)
413 }
414 if err := g.Init(ctx, "bad", "main", "md5"); !errors.Is(err, ErrInvalidFormat) {
415 t.Fatalf("init with md5: %v", err)
416 }
417
418 if err := g.SetObjectFormat(ctx, "empty", "sha1"); err != nil {
419 t.Fatal(err)
420 }
421 if f, _ := g.ObjectFormat(ctx, "empty"); f != "sha1" {
422 t.Fatalf("format after switch = %q", f)
423 }
424 if head, _ := g.line(ctx, "-C", g.RepoPath("empty"), "symbolic-ref", "--short", "HEAD"); head != "trunk" {
425 t.Fatalf("HEAD after switch = %q", head)
426 }
427 for _, leftover := range []string{".reinit", ".old"} {
428 if _, err := os.Stat(g.RepoPath("empty") + leftover); !os.IsNotExist(err) {
429 t.Errorf("%s left behind: %v", leftover, err)
430 }
431 }
432
433 before, _ := g.ObjectFormat(ctx, repo)
434 other := "sha256"
435 if before == other {
436 other = "sha1"
437 }
438 if err := g.SetObjectFormat(ctx, repo, other); !errors.Is(err, ErrNotEmpty) {
439 t.Fatalf("switch on repo with commits: %v", err)
440 }
441 if f, _ := g.ObjectFormat(ctx, repo); f != before {
442 t.Fatalf("non-empty repo format changed from %q to %q", before, f)
443 }
444}
445
446func TestEnvWithProtocol(t *testing.T) {
447 for _, tc := range []struct {
448 in string
449 want bool
450 }{
451 {"version=2", true},
452 {"version=2:object-format=sha256", true},
453 {"", false},
454 {"version=2\nGIT_DIR=/etc", false},
455 {"version=2 x", false},
456 {strings.Repeat("a", 65), false},
457 } {
458 env := EnvWithProtocol(tc.in)
459 got := env[len(env)-1] == "GIT_PROTOCOL="+tc.in
460 if got != tc.want {
461 t.Errorf("EnvWithProtocol(%q) forwarded = %v, want %v", tc.in, got, tc.want)
462 }
463 }
464}
465