package gitcmd import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) const ( aliceName = "Alice" aliceEmail = "alice@example.com" bobName = "Bob" bobEmail = "bob@example.com" readmeV1 = "line1\nline2\n" readmeV2 = "line1\nline3\n" mainTxt = "main line\n" deletedTxt = "to be deleted\n" ) var binaryContent = []byte{'B', 0x00, 'i', 'n', 0x00, '!', '*', '*'} const ( d1 = "2024-01-01T10:00:00+00:00" d2 = "2024-01-02T10:00:00+00:00" d3 = "2024-01-03T10:00:00+00:00" d4 = "2024-01-04T10:00:00+00:00" d5 = "2024-01-05T10:00:00+00:00" ) func mustParseTime(t *testing.T, s string) time.Time { t.Helper() tm, err := time.Parse(time.RFC3339, s) if err != nil { t.Fatalf("parse time %q: %v", s, err) } return tm } type fixture struct { repo *Repository c1 string c2 string c3 string c4 string c5 string } func requireGit(t *testing.T) string { t.Helper() p, err := exec.LookPath("git") if err != nil { t.Skipf("git not found on PATH: %v", err) } return p } var isolateEnv = []string{"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} func gitRun(t *testing.T, dir string, env []string, args ...string) { t.Helper() cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } cmd.Env = append(os.Environ(), isolateEnv...) cmd.Env = append(cmd.Env, env...) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v in %q: %v\n%s", args, dir, err, out) } } func headSHA(t *testing.T, dir string) string { t.Helper() out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD in %q: %v", dir, err) } return strings.TrimSpace(string(out)) } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func writeFileBytes(t *testing.T, path string, content []byte) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) } } func commitEnv(name, email, dateRFC3339 string) []string { return []string{ "GIT_AUTHOR_NAME=" + name, "GIT_AUTHOR_EMAIL=" + email, "GIT_AUTHOR_DATE=" + dateRFC3339, "GIT_COMMITTER_NAME=" + name, "GIT_COMMITTER_EMAIL=" + email, "GIT_COMMITTER_DATE=" + dateRFC3339, } } func commitAs(t *testing.T, dir, name, email, date, msg string) { t.Helper() gitRun(t, dir, nil, "add", "-A") gitRun(t, dir, commitEnv(name, email, date), "commit", "-q", "-m", msg) } func newFixture(t *testing.T) *fixture { t.Helper() requireGit(t) dir := t.TempDir() work := filepath.Join(dir, "work") bare := filepath.Join(dir, "bare.git") gitRun(t, "", nil, "init", "-q", "-b", "main", work) writeFile(t, filepath.Join(work, "README.md"), readmeV1) commitAs(t, work, aliceName, aliceEmail, d1, "add readme") c1 := headSHA(t, work) writeFile(t, filepath.Join(work, "src", "main.txt"), mainTxt) writeFile(t, filepath.Join(work, "deleted.txt"), deletedTxt) commitAs(t, work, bobName, bobEmail, d2, "add main and temp") c2 := headSHA(t, work) writeFile(t, filepath.Join(work, "README.md"), readmeV2) commitAs(t, work, aliceName, aliceEmail, d3, "expand readme") c3 := headSHA(t, work) gitRun(t, work, nil, "branch", "feature") writeFileBytes(t, filepath.Join(work, "binary.bin"), binaryContent) commitAs(t, work, aliceName, aliceEmail, d4, "add binary") c4 := headSHA(t, work) if err := os.Remove(filepath.Join(work, "deleted.txt")); err != nil { t.Fatal(err) } commitAs(t, work, bobName, bobEmail, d5, "remove temp") c5 := headSHA(t, work) gitRun(t, work, commitEnv(aliceName, aliceEmail, d1), "tag", "-a", "v1.0", c1, "-m", "release v1.0") gitRun(t, work, nil, "tag", "v0.9", c2) gitRun(t, "", nil, "clone", "--bare", "-q", work, bare) repo, err := OpenRepository(context.Background(), bare) if err != nil { t.Fatalf("OpenRepository: %v", err) } return &fixture{repo: repo, c1: c1, c2: c2, c3: c3, c4: c4, c5: c5} } func newMergeFixture(t *testing.T) *fixture { t.Helper() requireGit(t) dir := t.TempDir() work := filepath.Join(dir, "work") bare := filepath.Join(dir, "bare.git") gitRun(t, "", nil, "init", "-q", "-b", "main", work) writeFile(t, filepath.Join(work, "a.txt"), "a\n") commitAs(t, work, "Merger", "m@x", "2024-02-01T10:00:00+00:00", "c1 a") c1 := headSHA(t, work) gitRun(t, work, nil, "branch", "feature") gitRun(t, work, nil, "checkout", "-q", "feature") writeFile(t, filepath.Join(work, "b.txt"), "b\n") commitAs(t, work, "Merger", "m@x", "2024-02-02T10:00:00+00:00", "c2 b") c2 := headSHA(t, work) gitRun(t, work, nil, "checkout", "-q", "main") writeFile(t, filepath.Join(work, "c.txt"), "c\n") commitAs(t, work, "Merger", "m@x", "2024-02-03T10:00:00+00:00", "c3 c") c3 := headSHA(t, work) gitRun(t, work, commitEnv("Merger", "m@x", "2024-02-04T10:00:00+00:00"), "merge", "--no-ff", "feature", "-m", "merge feature") c4 := headSHA(t, work) gitRun(t, "", nil, "clone", "--bare", "-q", work, bare) repo, err := OpenRepository(context.Background(), bare) if err != nil { t.Fatalf("OpenRepository: %v", err) } return &fixture{repo: repo, c1: c1, c2: c2, c3: c3, c4: c4} } func newDetachedHeadRepo(t *testing.T) *Repository { t.Helper() requireGit(t) dir := t.TempDir() bare := filepath.Join(dir, "detached.git") gitRun(t, "", nil, "init", "-q", "--bare", bare) if err := os.WriteFile(filepath.Join(bare, "HEAD"), []byte("0123456789abcdef0123456789abcdef01234567\n"), 0o644); err != nil { t.Fatal(err) } repo, err := OpenRepository(context.Background(), bare) if err != nil { t.Fatalf("OpenRepository: %v", err) } return repo } func TestFixtureSmoke(t *testing.T) { f := newFixture(t) cs, err := f.repo.ListCommits("HEAD", 0) if err != nil { t.Fatal(err) } if len(cs) != 5 { t.Fatalf("commits = %d, want 5", len(cs)) } if cs[0].ID != f.c5 || cs[4].ID != f.c1 { t.Errorf("commit order: first=%s (want %s), last=%s (want %s)", cs[0].ID, f.c5, cs[4].ID, f.c1) } bs, err := f.repo.ListBranches() if err != nil { t.Fatal(err) } if len(bs) != 2 { t.Fatalf("branches = %d, want 2", len(bs)) } branchByName := map[string]Branch{} for _, b := range bs { branchByName[b.Name] = b } if main, ok := branchByName["main"]; !ok || !main.IsDefault || main.SHA != f.c5 { t.Errorf("main branch = %+v", branchByName["main"]) } if feat, ok := branchByName["feature"]; !ok || feat.SHA != f.c3 { t.Errorf("feature branch = %+v", branchByName["feature"]) } ts, err := f.repo.ListTags() if err != nil { t.Fatal(err) } if len(ts) != 2 { t.Fatalf("tags = %d, want 2", len(ts)) } contribs, err := f.repo.GetContributors("HEAD") if err != nil { t.Fatal(err) } if len(contribs) != 2 { t.Fatalf("contributors = %d, want 2", len(contribs)) } if contribs[0].Author != aliceName || contribs[0].Commits != 3 { t.Errorf("contribs[0] = %+v, want Alice/3", contribs[0]) } if contribs[1].Author != bobName || contribs[1].Commits != 2 { t.Errorf("contribs[1] = %+v, want Bob/2", contribs[1]) } if db, err := f.repo.DefaultBranch(); err != nil || db != "main" { t.Errorf("DefaultBranch = %q, %v", db, err) } }