package gitcmd import ( "bytes" "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/klauspost/compress/zstd" "hearthforge/internal/config" ) // newTestGit builds a bare repo with one commit and returns a Git for it. func newTestGit(t *testing.T) (*Git, string) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } dir := t.TempDir() cfg := &config.Config{ DataDir: dir, BaseURL: "http://localhost:3000", MaxConcurrentArchives: 2, SSHHostKeyPath: filepath.Join(dir, "ssh_host_key"), } if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { t.Fatal(err) } work := filepath.Join(dir, "work") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatal(err) } run := func(cwd string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = cwd cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } run(work, "init", "-q", "-b", "main") run(work, "config", "user.email", "t@example.com") run(work, "config", "user.name", "Test") if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hello archive\n"), 0o644); err != nil { t.Fatal(err) } run(work, "add", "-A") run(work, "commit", "-q", "-m", "init") run(dir, "clone", "-q", "--bare", work, filepath.Join(cfg.ReposDir(), "archivetest.git")) return New(cfg), "archivetest" } func TestLsTreeShowLog(t *testing.T) { g, repo := newTestGit(t) ctx := context.Background() entries, err := g.LsTree(ctx, repo, "main", "") if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name != "hello.txt" || entries[0].Type != "blob" { t.Fatalf("unexpected tree: %+v", entries) } blob, err := g.Show(ctx, repo, "main", "hello.txt") if err != nil { t.Fatal(err) } if string(blob) != "hello archive\n" { t.Fatalf("blob = %q", blob) } if _, err := g.Show(ctx, repo, "main", "missing.txt"); err == nil { t.Fatal("expected error for missing path") } commits, err := g.Log(ctx, repo, "main", 10, 0) if err != nil { t.Fatal(err) } if len(commits) != 1 || commits[0].Subject != "init" || commits[0].Author != "Test" { t.Fatalf("unexpected log: %+v", commits) } if commits[0].SigStatus != SigNone { t.Fatalf("sig = %q", commits[0].SigStatus) } } func TestArchiveAllFormats(t *testing.T) { g, repo := newTestGit(t) out := t.TempDir() if err := g.Archive(context.Background(), repo, "main", "slug", out); err != nil { t.Fatal(err) } for _, ext := range []string{".zip", ".tar.gz", ".tar.zst"} { st, err := os.Stat(filepath.Join(out, "slug-main"+ext)) if err != nil { t.Fatalf("%s: %v", ext, err) } if st.Size() == 0 { t.Fatalf("%s is empty", ext) } } compressed, err := os.ReadFile(filepath.Join(out, "slug-main.tar.zst")) if err != nil { t.Fatal(err) } if !bytes.HasPrefix(compressed, []byte{0x28, 0xb5, 0x2f, 0xfd}) { t.Fatal("missing zstd magic number") } dec, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { t.Fatal(err) } defer dec.Close() tar, err := dec.DecodeAll(compressed, nil) if err != nil { t.Fatal(err) } // tar stores names as plain text in each 512-byte header block. if !bytes.Contains(tar, []byte("hello.txt")) { t.Fatal("tar does not contain hello.txt") } if len(tar)%512 != 0 { t.Fatalf("tar length %d is not a multiple of 512", len(tar)) } } func TestArchiveBadRef(t *testing.T) { g, repo := newTestGit(t) out := t.TempDir() if err := g.Archive(context.Background(), repo, "no-such-ref", "slug", out); err == nil { t.Fatal("expected error for unknown ref") } if _, err := os.Stat(filepath.Join(out, "slug-no-such-ref.tar.zst")); err == nil { t.Fatal("partial zst artifact left behind") } } func TestNameAndRefValidation(t *testing.T) { for _, bad := range []string{"", "..", "a/b", "../etc", "a b", "re$po"} { if ValidRepoName(bad) { t.Errorf("repo name %q should be rejected", bad) } } for _, good := range []string{"repo", "my.repo", "my-repo_1"} { if !ValidRepoName(good) { t.Errorf("repo name %q should be accepted", good) } } for _, bad := range []string{"", "--output=/tmp/x", "-rf", "a..b", "a b", "a:b", "a\\b"} { if ValidRef(bad) { t.Errorf("ref %q should be rejected", bad) } } for _, good := range []string{"main", "refs/heads/main", "v1.0", "abc123"} { if !ValidRef(good) { t.Errorf("ref %q should be accepted", good) } } for _, bad := range []string{"", "/etc/passwd", "-x", "a/../b"} { if ValidPath(bad) { t.Errorf("path %q should be rejected", bad) } } g, repo := newTestGit(t) if _, err := g.Log(context.Background(), repo, "--output=/tmp/pwn", 1, 0); err == nil { t.Fatal("expected invalid ref error") } if _, err := g.LsTree(context.Background(), "../escape", "main", ""); err == nil { t.Fatal("expected invalid repo name error") } } func TestWriteOpsAndSigning(t *testing.T) { if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen not installed") } g, repo := newTestGit(t) ctx := context.Background() if err := g.EnsureSigningSetup(); err != nil { t.Fatal(err) } who := Ident{Name: "Bot", Email: "bot@example.com"} sha, err := g.EditFile(ctx, repo, "main", "", "", "docs/new.txt", []byte("new\n"), "add file", who) if err != nil { t.Fatal(err) } commits, err := g.Log(ctx, repo, "main", 1, 0) if err != nil { t.Fatal(err) } if commits[0].Hash != sha || commits[0].SigStatus != SigGood { t.Fatalf("expected signed commit %s, got %+v", sha, commits[0]) } if _, err := g.EditFile(ctx, repo, "main", "", "docs/new.txt", "docs/moved.txt", []byte("new\n"), "move", who); err != nil { t.Fatal(err) } entries, err := g.LsTree(ctx, repo, "main", "docs") if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name != "moved.txt" { t.Fatalf("after move: %+v", entries) } if _, err := g.DeleteFile(ctx, repo, "main", "", "docs/moved.txt", "delete", who); err != nil { t.Fatal(err) } if _, err := g.Show(ctx, repo, "main", "docs/moved.txt"); err == nil { t.Fatal("deleted file still present") } if err := g.CreateBranch(ctx, repo, "feature", "main"); err != nil { t.Fatal(err) } if err := g.CreateBranch(ctx, repo, "feature", "main"); !errors.Is(err, ErrExists) { t.Fatalf("want ErrExists, got %v", err) } if err := g.CreateBranch(ctx, repo, "other", "nope"); !errors.Is(err, ErrBadRef) { t.Fatalf("want ErrBadRef, got %v", err) } if err := g.RenameBranch(ctx, repo, "feature", "feature2"); err != nil { t.Fatal(err) } if err := g.DeleteBranch(ctx, repo, "feature2"); err != nil { t.Fatal(err) } if err := g.DeleteBranch(ctx, repo, "feature2"); !errors.Is(err, ErrNotFound) { t.Fatalf("want ErrNotFound, got %v", err) } if err := g.CreateTag(ctx, repo, "v1.0", "main", "release one", who); err != nil { t.Fatal(err) } tags, err := g.TagsWithInfo(ctx, repo, 0) if err != nil { t.Fatal(err) } if len(tags) != 1 || tags[0].Name != "v1.0" || !tags[0].IsAnnotated { t.Fatalf("tags: %+v", tags) } if err := g.CreateTag(ctx, repo, "v1.0", "main", "", who); !errors.Is(err, ErrExists) { t.Fatalf("want ErrExists, got %v", err) } } func TestPatchApply(t *testing.T) { if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen not installed") } g, repo := newTestGit(t) ctx := context.Background() if err := g.EnsureSigningSetup(); err != nil { t.Fatal(err) } patch := "Subject: [PATCH] change greeting\n\n" + "---\n" + "diff --git a/hello.txt b/hello.txt\n" + "--- a/hello.txt\n" + "+++ b/hello.txt\n" + "@@ -1 +1 @@\n" + "-hello archive\n" + "+goodbye archive\n" res, err := g.CheckPatch(ctx, repo, patch) if err != nil || res.Status != "clean" { t.Fatalf("check: %v %+v", err, res) } if res, _ := g.CheckPatch(ctx, repo, strings.Replace(patch, "-hello archive", "-nope", 1)); res.Status != "conflict" { t.Fatalf("expected conflict, got %+v", res) } if _, err := g.ApplyPatch(ctx, repo, patch, Ident{"A", "a@x"}, Ident{"C", "c@x"}); err != nil { t.Fatal(err) } blob, err := g.Show(ctx, repo, "HEAD", "hello.txt") if err != nil { t.Fatal(err) } if string(blob) != "goodbye archive\n" { t.Fatalf("blob = %q", blob) } if meta := ExtractPatchMeta(patch); meta.Subject != "change greeting" { t.Fatalf("meta = %+v", meta) } } func TestPatchCommitMessage(t *testing.T) { patch := "From abc Mon Sep 17 00:00:00 2001\n" + "From: Ada \n" + "Date: Thu, 1 Jan 2026 00:00:00 +0000\n" + "Subject: [PATCH 1/2] Fix the widget\n" + "\n" + "The widget was off by one.\n" + "\n" + "Signed-off-by: Ada \n" + "---\n" + " a.txt | 1 +\n" want := "Fix the widget\n\nThe widget was off by one.\n\nSigned-off-by: Ada " if got := PatchCommitMessage(patch); got != want { t.Fatalf("got %q want %q", got, want) } // A patch with no body keeps just the subject. if got := PatchCommitMessage("Subject: Only a subject\n\n---\n"); got != "Only a subject" { t.Fatalf("got %q", got) } } // git format-patch folds a long subject over several lines and encodes // non-ASCII names as RFC 2047 words. func TestExtractPatchMetaFoldedAndEncodedHeaders(t *testing.T) { patch := "From abc Mon Sep 17 00:00:00 2001\n" + "From: =?UTF-8?q?J=C3=B6rg=20M=C3=BCller?= \n" + "Date: Thu, 1 Jan 2026 00:00:00 +0000\n" + "Subject: [PATCH] Make the widget stop counting\n" + " from the wrong end\n" + "\n" + "Body line.\n" + "---\n" + " a.txt | 1 +\n" m := ExtractPatchMeta(patch) if m.Author != "Jörg Müller" || m.Email != "j@x.de" { t.Fatalf("author = %q <%q>", m.Author, m.Email) } if m.Subject != "Make the widget stop counting from the wrong end" { t.Fatalf("subject = %q", m.Subject) } if m.Body != "Body line." { t.Fatalf("body = %q", m.Body) } } // An encoded subject split over two lines joins without the folding space. func TestExtractPatchMetaFoldedEncodedSubject(t *testing.T) { patch := "Subject: =?UTF-8?q?Gr=C3=BC=C3=9F?=\n =?UTF-8?q?e?=\n\nbody\n---\n" if got := ExtractPatchMeta(patch).Subject; got != "Grüße" { t.Fatalf("subject = %q", got) } } func TestParseSigStatus(t *testing.T) { for code, want := range map[string]SigStatus{ "G": SigGood, "B": SigBad, "R": SigBad, "U": SigUnverified, "X": SigUnverified, "Y": SigUnverified, "E": SigUnverified, "N": SigNone, "": SigNone, } { if got := parseSigStatus(code); got != want { t.Errorf("parseSigStatus(%q) = %q, want %q", code, got, want) } } } func TestLsTreeNonASCIIName(t *testing.T) { g, repo := newSigningGit(t) if _, err := g.EditFile(context.Background(), repo, "main", "", "", "dir/ä ö.txt", []byte("x\n"), "add", testWho); err != nil { t.Fatal(err) } entries, err := g.LsTree(context.Background(), repo, "main", "dir") if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name != "ä ö.txt" || entries[0].Size != "2" { t.Fatalf("entries = %+v", entries) } } func TestDiffLimitAndBlobSizes(t *testing.T) { g, repo := newSigningGit(t) ctx := context.Background() sha, err := g.EditFile(ctx, repo, "main", "", "", "big.txt", []byte(strings.Repeat("line\n", 1000)), "add", testWho) if err != nil { t.Fatal(err) } if _, err := g.Diff(ctx, repo, sha, 1000); !errors.Is(err, ErrTooLarge) { t.Fatalf("want ErrTooLarge, got %v", err) } out, err := g.Diff(ctx, repo, sha, 1<<20) if err != nil || !strings.Contains(out, "+++ b/big.txt") { t.Fatalf("diff = %q, %v", out, err) } hello, _ := g.run(ctx, runOpts{}, "-C", g.RepoPath(repo), "rev-parse", "main:hello.txt") blob := strings.TrimSpace(string(hello)) sizes, err := g.BlobSizes(ctx, repo, []string{blob[:7], "deadbeef"}) if err != nil { t.Fatal(err) } if len(sizes) != 1 || sizes[blob[:7]] != int64(len("hello archive\n")) { t.Fatalf("sizes = %v", sizes) } }