package gitx
import (
"context"
"errors"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
func ctx() context.Context { return context.Background() }
func TestOpen(t *testing.T) {
reposRoot := newFixtureRepo(t)
t.Run("valid", func(t *testing.T) {
repo, err := Open(reposRoot, fxOwner, fxName)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !strings.HasSuffix(repo.Dir(), "~alice/demo") {
t.Errorf("dir = %q", repo.Dir())
}
})
// Every hostile input must fail with ErrNotFound and never escape reposRoot.
traversal := []struct {
name string
owner, repo string
}{
{"dotdot owner", "..", "demo"},
{"tilde owner", "~evil", "demo"},
{"slash in repo", "alice", "../../etc"},
{"dotdot repo", "alice", ".."},
{"absolute-ish repo", "alice", "/etc/passwd"},
{"leading dash owner", "-x", "demo"},
{"missing repo", "alice", "nonexistent"},
{"missing owner", "bob", "demo"},
}
for _, tt := range traversal {
t.Run(tt.name, func(t *testing.T) {
_, err := Open(reposRoot, tt.owner, tt.repo)
if err == nil {
t.Fatalf("Open(%q,%q) succeeded, want error", tt.owner, tt.repo)
}
if !errors.Is(err, core.ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
}
}
func TestRefs(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
branches, tags, err := repo.Refs(ctx())
if err != nil {
t.Fatalf("Refs: %v", err)
}
if len(branches) != 2 {
t.Fatalf("branches = %v, want 2", branches)
}
if branches[0].Name != "main" {
t.Errorf("branches[0] = %q, want HEAD branch main first", branches[0].Name)
}
if branches[1].Name != "feature/with-slash" {
t.Errorf("branches[1] = %q, want feature/with-slash", branches[1].Name)
}
if branches[0].SHA != fxRev(t, reposRoot, "main") {
t.Errorf("main SHA = %q, want %q", branches[0].SHA, fxRev(t, reposRoot, "main"))
}
if len(tags) != 2 {
t.Fatalf("tags = %v, want 2", tags)
}
// Newest creator-date first: v1.0.0 was tagged after v0.9.0.
if tags[0].Name != "v1.0.0" || tags[1].Name != "v0.9.0" {
t.Errorf("tags order = [%q %q], want [v1.0.0 v0.9.0]", tags[0].Name, tags[1].Name)
}
def, err := repo.DefaultBranch(ctx())
if err != nil {
t.Fatalf("DefaultBranch: %v", err)
}
if def != "main" {
t.Errorf("DefaultBranch = %q, want main", def)
}
}
func TestResolveCommit(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
mainSHA := fxRev(t, reposRoot, "main")
rootSHA := fxRev(t, reposRoot, "main^1~3")
t.Run("branch", func(t *testing.T) {
ci, err := repo.ResolveCommit(ctx(), "main")
if err != nil {
t.Fatalf("ResolveCommit(main): %v", err)
}
if ci.SHA != mainSHA {
t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
}
if len(ci.ParentSHAs) != 2 {
t.Errorf("main is a merge, parents = %v", ci.ParentSHAs)
}
if ci.AuthorName != "Alice Example" {
t.Errorf("author = %q", ci.AuthorName)
}
})
t.Run("annotated tag peels to commit", func(t *testing.T) {
ci, err := repo.ResolveCommit(ctx(), "v1.0.0")
if err != nil {
t.Fatalf("ResolveCommit(v1.0.0): %v", err)
}
if ci.SHA != mainSHA {
t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
}
})
t.Run("full SHA", func(t *testing.T) {
ci, err := repo.ResolveCommit(ctx(), rootSHA)
if err != nil {
t.Fatalf("ResolveCommit(root SHA): %v", err)
}
if ci.SHA != rootSHA {
t.Errorf("SHA = %q, want %q", ci.SHA, rootSHA)
}
if len(ci.ParentSHAs) != 0 {
t.Errorf("root parents = %v, want none", ci.ParentSHAs)
}
})
t.Run("branch with slash", func(t *testing.T) {
ci, err := repo.ResolveCommit(ctx(), "feature/with-slash")
if err != nil {
t.Fatalf("ResolveCommit(feature/with-slash): %v", err)
}
if ci.SHA != fxRev(t, reposRoot, "feature/with-slash") {
t.Errorf("SHA = %q", ci.SHA)
}
})
t.Run("nonexistent ref is ErrBadRef", func(t *testing.T) {
_, err := repo.ResolveCommit(ctx(), "does-not-exist")
if !errors.Is(err, core.ErrBadRef) {
t.Errorf("err = %v, want ErrBadRef", err)
}
})
t.Run("malformed ref is ErrBadRef", func(t *testing.T) {
_, err := repo.ResolveCommit(ctx(), "@")
if !errors.Is(err, core.ErrBadRef) {
t.Errorf("err = %v, want ErrBadRef", err)
}
})
}
func TestLog(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
base := fxRev(t, reposRoot, "main^1~2") // c2
head := fxRev(t, reposRoot, "main^1") // binary commit
commits, err := repo.Log(ctx(), base, head, 10)
if err != nil {
t.Fatalf("Log: %v", err)
}
if len(commits) != 2 {
t.Fatalf("commits = %d, want 2 (rename, binary)", len(commits))
}
// Newest first.
if !strings.HasPrefix(commits[0].Subject, "binary") {
t.Errorf("commits[0] = %q, want binary", commits[0].Subject)
}
if !strings.HasPrefix(commits[1].Subject, "rename") {
t.Errorf("commits[1] = %q, want rename", commits[1].Subject)
}
t.Run("limit", func(t *testing.T) {
commits, err := repo.Log(ctx(), base, head, 1)
if err != nil {
t.Fatalf("Log: %v", err)
}
if len(commits) != 1 {
t.Errorf("commits = %d, want 1", len(commits))
}
})
}
func TestDiffThreeDotVsTwoDot(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
// base = binary commit (has rename + binary on its side), head = feature.
// Their merge base is c2. Three-dot must show only head-side changes
// (feature.txt); two-dot compares the endpoints directly and surfaces the
// base-only files.
base := fxRev(t, reposRoot, "main^1")
head := "feature/with-slash" // slash branch name passes through verbatim
three, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: true})
if err != nil {
t.Fatalf("Diff three-dot: %v", err)
}
if !strings.Contains(three.Text, "feature.txt") {
t.Errorf("three-dot diff missing feature.txt")
}
if strings.Contains(three.Text, "bin.dat") {
t.Errorf("three-dot diff should NOT contain base-only bin.dat")
}
two, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: false})
if err != nil {
t.Fatalf("Diff two-dot: %v", err)
}
if !strings.Contains(two.Text, "bin.dat") {
t.Errorf("two-dot diff should surface base-side bin.dat")
}
}
func TestDiffStat(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
// c2 -> binary: a.txt renamed to a-renamed.txt, bin.dat added (binary).
spec := core.CompareSpec{
Base: fxRev(t, reposRoot, "main^1~2"),
Head: fxRev(t, reposRoot, "main^1"),
}
files, err := repo.DiffStat(ctx(), spec)
if err != nil {
t.Fatalf("DiffStat: %v", err)
}
byPath := map[string]FileChange{}
for _, f := range files {
byPath[f.Path] = f
}
ren, ok := byPath["a-renamed.txt"]
if !ok {
t.Fatalf("missing rename entry; files = %+v", files)
}
if ren.Status != "R" || ren.OldPath != "a.txt" {
t.Errorf("rename = %+v, want R from a.txt", ren)
}
bin, ok := byPath["bin.dat"]
if !ok {
t.Fatalf("missing bin.dat; files = %+v", files)
}
if bin.Status != "A" || !bin.Binary {
t.Errorf("bin.dat = %+v, want A binary", bin)
}
if bin.Additions != 0 || bin.Deletions != 0 {
t.Errorf("binary counts = %d/%d, want 0/0", bin.Additions, bin.Deletions)
}
}
func TestTruncation(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
spec := core.CompareSpec{Base: fxRev(t, reposRoot, "main^1~3"), Head: "main", ThreeDot: true}
full, err := repo.Diff(ctx(), spec)
if err != nil {
t.Fatalf("Diff (uncapped): %v", err)
}
if full.Truncated {
t.Fatalf("baseline diff unexpectedly truncated")
}
if len(full.Text) <= 32 {
t.Fatalf("fixture diff too small (%d bytes) to test truncation", len(full.Text))
}
repo.limitOverride = 32
capped, err := repo.Diff(ctx(), spec)
if err != nil {
t.Fatalf("Diff (capped): %v", err)
}
if !capped.Truncated {
t.Errorf("capped diff not marked truncated")
}
if len(capped.Text) > 32 {
t.Errorf("capped diff = %d bytes, want <= 32", len(capped.Text))
}
}
func TestMergeBase(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
got, err := repo.MergeBase(ctx(), fxRev(t, reposRoot, "main^1"), "feature/with-slash")
if err != nil {
t.Fatalf("MergeBase: %v", err)
}
want := fxRev(t, reposRoot, "main^1~2") // c2
if got != want {
t.Errorf("MergeBase = %q, want %q (c2)", got, want)
}
}
func TestParents(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
t.Run("merge", func(t *testing.T) {
parents, err := repo.Parents(ctx(), "main")
if err != nil {
t.Fatalf("Parents: %v", err)
}
if len(parents) != 2 {
t.Errorf("parents = %v, want 2", parents)
}
})
t.Run("root", func(t *testing.T) {
parents, err := repo.Parents(ctx(), fxRev(t, reposRoot, "main^1~3"))
if err != nil {
t.Fatalf("Parents: %v", err)
}
if len(parents) != 0 {
t.Errorf("root parents = %v, want none", parents)
}
})
}
func TestCommitPatch(t *testing.T) {
reposRoot := newFixtureRepo(t)
repo := openFixture(t, reposRoot)
t.Run("root commit", func(t *testing.T) {
root := fxRev(t, reposRoot, "main^1~3")
patch, files, ci, err := repo.CommitPatch(ctx(), root)
if err != nil {
t.Fatalf("CommitPatch(root): %v", err)
}
if ci.SHA != root || len(ci.ParentSHAs) != 0 {
t.Errorf("ci = %+v, want root with no parents", ci)
}
if !strings.Contains(patch.Text, "a.txt") {
t.Errorf("root patch missing a.txt")
}
if len(files) != 1 || files[0].Path != "a.txt" || files[0].Status != "A" {
t.Errorf("root files = %+v, want single A a.txt", files)
}
})
t.Run("binary commit", func(t *testing.T) {
bin := fxRev(t, reposRoot, "main^1")
patch, files, _, err := repo.CommitPatch(ctx(), bin)
if err != nil {
t.Fatalf("CommitPatch(binary): %v", err)
}
if len(files) != 1 || files[0].Path != "bin.dat" || !files[0].Binary {
t.Errorf("binary commit files = %+v, want single binary bin.dat", files)
}
if !strings.Contains(patch.Text, "bin.dat") {
t.Errorf("binary patch missing bin.dat header")
}
})
t.Run("merge commit diffs against first parent", func(t *testing.T) {
patch, files, ci, err := repo.CommitPatch(ctx(), "main")
if err != nil {
t.Fatalf("CommitPatch(merge): %v", err)
}
if len(ci.ParentSHAs) != 2 {
t.Fatalf("expected merge commit, parents = %v", ci.ParentSHAs)
}
// vs first parent (binary commit), the merge brings in feature.txt.
if len(files) != 1 || files[0].Path != "feature.txt" {
t.Errorf("merge files = %+v, want feature.txt", files)
}
if !strings.Contains(patch.Text, "feature.txt") {
t.Errorf("merge patch missing feature.txt (diff-tree -p on a merge is empty; must diff vs parent)")
}
})
}