package gitx
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/go-git/go-git/v5/plumbing/format/diff"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
// The benchmarks of the diffing path — what this service spends a request in.
// Every page it serves is one of these calls plus a template render: Diff and
// DiffStat behind /~owner/repo/compare/base..head, CommitPatch behind
// /~owner/repo/commit/rev, mapFilePatches and cutPatch inside all three.
//
// They are uploaded to bench.srht.bigb.es by the `bench` task of .build.yml, so
// their names are part of that pipeline: the task greps for them before it
// POSTs, because `go test -bench` that matches nothing still prints "ok" and
// exits 0 (docs/ci.md#bench).
//
// A builds.sr.ht VM measures a shape rather than a number — it is a shared
// virtual machine with no CPU pinning — so what is worth reading off the series
// is a step, not a millisecond.
const (
// benchFiles and benchLines size the fixture repository. They are what makes
// this a diff benchmark rather than a go-git-open benchmark: the existing
// test fixture's whole history is a dozen lines, which the render finishes
// before the tree walk has paid for itself.
//
// The pair produces a ~30 000-line base tree and a head commit touching a
// quarter of it, which lands a two-dot patch in the tens of kilobytes — the
// size of an ordinary change under review, and small enough that a run at
// the family's -count=10 stays inside a CI VM's patience.
benchFiles = 40
benchLines = 120
// benchModified is how many of the files the feature branch edits.
benchModified = 12
)
// benchRepo is the fixture the diff benchmarks measure against:
//
// seed ── main-work (branch: main)
// \
// feature-work (branch: feature)
//
// seed : benchFiles files of benchLines lines each
// main-work : edits 4 of them, so main and feature have really diverged
// feature-work: edits benchModified, adds 3, deletes 2, renames 1
//
// The divergence is the point: with main advanced past the fork, "main..feature"
// and "main...feature" are different comparisons, and the three-dot form (the
// one a review page uses) has a merge base to find.
type benchRepo struct {
root string // reposRoot, the directory Open takes
main string // SHA of main
head string // SHA of feature
}
// benchLineFile renders one fixture file. The seed and the edit differ in a
// handful of lines out of benchLines, so the hunks are hunks and not whole-file
// rewrites — a rewrite would measure go-git's line differ on its easiest input.
func benchLineFile(name string, edited bool) []byte {
var b strings.Builder
for i := range benchLines {
switch {
case edited && i%17 == 0:
fmt.Fprintf(&b, "%s line %d edited by the feature branch\n", name, i)
default:
fmt.Fprintf(&b, "%s line %d with enough text to look like source\n", name, i)
}
}
return []byte(b.String())
}
func benchFileName(i int) string { return fmt.Sprintf("src/f%02d.txt", i) }
// newBenchRepo builds the bare fixture repository under a temporary directory
// owned by b, and returns the handles the benchmarks resolve against.
func newBenchRepo(b *testing.B) benchRepo {
b.Helper()
if _, err := exec.LookPath("git"); err != nil {
b.Skipf("git not available: %v", err)
}
root := b.TempDir()
work := b.TempDir()
d1 := "2024-03-01T00:00:00Z"
d2 := "2024-03-02T00:00:00Z"
d3 := "2024-03-03T00:00:00Z"
gitTest(b, work, d1, "init", "-b", "main")
for i := range benchFiles {
writeFile(b, work, benchFileName(i), benchLineFile(benchFileName(i), false))
}
gitTest(b, work, d1, "add", "-A")
gitTest(b, work, d1, "commit", "-m", "seed: the tree under comparison")
gitTest(b, work, d2, "branch", "feature")
// main advances past the fork point, so the merge base is not main.
for i := range 4 {
name := benchFileName(i)
writeFile(b, work, name, benchLineFile(name+" on main", true))
}
gitTest(b, work, d2, "add", "-A")
gitTest(b, work, d2, "commit", "-m", "main-work: edits on the base branch")
gitTest(b, work, d3, "checkout", "feature")
for i := range benchModified {
name := benchFileName(i)
writeFile(b, work, name, benchLineFile(name, true))
}
for i := range 3 {
name := fmt.Sprintf("src/added%02d.txt", i)
writeFile(b, work, name, benchLineFile(name, false))
}
for i := benchFiles - 2; i < benchFiles; i++ {
if err := os.Remove(filepath.Join(work, benchFileName(i))); err != nil {
b.Fatal(err)
}
}
gitTest(b, work, d3, "mv", benchFileName(20), "src/renamed.txt")
gitTest(b, work, d3, "add", "-A")
gitTest(b, work, d3, "commit", "-m", "feature-work: edits, additions, deletions, a rename")
if err := os.MkdirAll(filepath.Join(root, "~"+fxOwner), 0o755); err != nil {
b.Fatal(err)
}
gitTest(b, work, d3, "clone", "--bare", work, filepath.Join(root, "~"+fxOwner, "benchrepo"))
return benchRepo{
root: root,
main: fxRevIn(b, root, "benchrepo", "main"),
head: fxRevIn(b, root, "benchrepo", "feature"),
}
}
// openBench opens the fixture through the code under test.
func openBench(b *testing.B, fx benchRepo) *Repo {
b.Helper()
repo, err := Open(fx.root, fxOwner, "benchrepo")
if err != nil {
b.Fatalf("Open: %v", err)
}
return repo
}
// BenchmarkDiff renders the unified diff a compare page shows, in both of the
// grammars ParseCompareSpec accepts. Three-dot is the costlier of the two and
// the one a review reaches for: it resolves a merge base before it can diff.
func BenchmarkDiff(b *testing.B) {
fx := newBenchRepo(b)
repo := openBench(b, fx)
ctx := context.Background()
for _, tc := range []struct {
name string
threeDot bool
}{
{"two-dot", false},
{"three-dot", true},
} {
b.Run(tc.name, func(b *testing.B) {
spec := core.CompareSpec{Base: fx.main, Head: fx.head, ThreeDot: tc.threeDot}
b.ReportAllocs()
for b.Loop() {
p, err := repo.Diff(ctx, spec)
if err != nil {
b.Fatalf("Diff: %v", err)
}
if p.Truncated {
b.Fatal("the fixture patch hit the byte cap: the benchmark is measuring cutPatch")
}
}
})
}
}
// BenchmarkDiffStat measures the file list of the same comparison. It is not a
// cheaper Diff and is not expected to read as one: the patch is generated in
// full either way, and what this leaves out is only the String() of it.
func BenchmarkDiffStat(b *testing.B) {
fx := newBenchRepo(b)
repo := openBench(b, fx)
ctx := context.Background()
spec := core.CompareSpec{Base: fx.main, Head: fx.head, ThreeDot: true}
b.ReportAllocs()
for b.Loop() {
files, err := repo.DiffStat(ctx, spec)
if err != nil {
b.Fatalf("DiffStat: %v", err)
}
if len(files) == 0 {
b.Fatal("no file changes: the fixture is not what the benchmark thinks it is")
}
}
}
// BenchmarkCommitPatch measures the single-commit view: resolve a revision,
// diff it against its first parent, render and map. It is the whole of what
// /~owner/repo/commit/<rev> does.
func BenchmarkCommitPatch(b *testing.B) {
fx := newBenchRepo(b)
repo := openBench(b, fx)
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
patch, files, info, err := repo.CommitPatch(ctx, fx.head)
if err != nil {
b.Fatalf("CommitPatch: %v", err)
}
if patch.Text == "" || len(files) == 0 || info == nil {
b.Fatal("empty commit patch: the fixture is not what the benchmark thinks it is")
}
}
}
// BenchmarkMapFilePatches measures the pure projection every diff surface runs
// over go-git's result. It uses the synthetic file patches of mapping_test.go
// rather than a repository, because it is the tallying of chunks that is being
// measured and a real diff would bury it under its own generation.
func BenchmarkMapFilePatches(b *testing.B) {
line := strings.Repeat("a line of content that is about this long\n", 8)
fps := make([]diff.FilePatch, 0, benchFiles)
for i := range benchFiles {
name := benchFileName(i)
fps = append(fps, fakeFilePatch{
from: file(name),
to: file(name),
chunks: []diff.Chunk{
fakeChunk{content: line, op: diff.Equal},
fakeChunk{content: line, op: diff.Delete},
fakeChunk{content: line, op: diff.Add},
fakeChunk{content: line, op: diff.Equal},
},
})
}
b.ReportAllocs()
for b.Loop() {
if got := mapFilePatches(fps); len(got) != benchFiles {
b.Fatalf("mapped %d file changes, want %d", len(got), benchFiles)
}
}
}
// BenchmarkCutPatch measures the byte cap on the two inputs that behave
// differently: text under the limit, which must be returned untouched, and text
// over it, which is scanned backwards for a file boundary. The second is the
// one a pathological diff reaches, and it is the reason the cap exists.
func BenchmarkCutPatch(b *testing.B) {
var sb strings.Builder
for i := range benchFiles {
name := benchFileName(i)
fmt.Fprintf(&sb, "diff --git a/%s b/%s\n--- a/%s\n+++ b/%s\n", name, name, name, name)
sb.Write(benchLineFile(name, false))
}
text := sb.String()
for _, tc := range []struct {
name string
limit int64
}{
{"under-the-cap", int64(len(text)) + 1},
{"over-the-cap", int64(len(text) / 2)},
} {
b.Run(tc.name, func(b *testing.B) {
wantTruncated := tc.limit < int64(len(text))
b.ReportAllocs()
for b.Loop() {
out, truncated := cutPatch(text, tc.limit)
if truncated != wantTruncated {
b.Fatalf("truncated=%v, want %v", truncated, wantTruncated)
}
if out == "" {
b.Fatal("cutPatch returned nothing")
}
}
})
}
}