~bigbes/sr-ht-compare

7dfec322eb11c76af7eabd4318daeda9a52ae232 — bigbes 2 days ago 2569ec4
ci: report this build's coverage and benchmarks

The pipeline built and published an apk and said nothing about the code it
packaged. It now runs the suite as `make cover COVERPROFILE=$HOME/cover.out`
and POSTs the profile to cov.srht.bigb.es under ~bigbes/sr-ht-compare — the
name on the sources: line, not the renamed service's.

`cover` is not a second suite: it is `test` with two flags reached through
TESTFLAGS, so CI's command and a developer's `make test` cannot become two
different runs. -covermode=atomic because this is a concurrent server and the
default set mode records that a statement ran rather than how often.

There was nothing to upload to bench.sr.ht either, gitx having no benchmark at
all. The diffing path — what a request here is actually spent in — now has
five: Diff in both of the grammars a compare URL can carry, DiffStat,
CommitPatch, and the two pure functions under them, mapFilePatches and
cutPatch. They build their own fixture, 40 files of 120 lines with a branch
that diverged, because the test fixture's dozen lines measure opening a
repository rather than diffing one.

`make -s bench` writes benchfmt to $HOME/bench.txt and three greps refuse to
upload a file the expected names are missing from: `go test -bench` that
matches nothing prints ok and exits 0, and an empty file is valid benchfmt.
Both uploads turn tracing off around the token, use --fail-with-body so a
refusal is printed and still fatal, and strip both ref prefixes because this
pipeline builds tags too.

cover.out and bench.txt are declared as artifacts, so a failed upload leaves
the report recoverable, and gitignored, because written into the checkout they
would stamp the packaged binary +dirty.

The manifest is now within 560 bytes of the 16 KiB a submission may carry; the
reasoning lives in docs/ci.md, which says so.
8 files changed, 605 insertions(+), 28 deletions(-)

M .build.yml
M .gitignore
M Makefile
M README.md
M docs/ci.md
A gitx/bench_test.go
M gitx/fidelity_test.go
M gitx/fixture_test.go
M .build.yml => .build.yml +83 -10
@@ 1,6 1,7 @@
# builds.sr.ht manifest for diff.sr.ht. One linear pipeline: install the
# cache helper, assemble the shared SCSS, restore caches, package with abuild,
# publish the apk, save the caches.
# publish the apk, save the caches, and upload this build's own coverage and
# benchmarks to cov.sr.ht and bench.sr.ht.
#
# The reasoning behind every task lives in docs/ci.md, not here: builds.sr.ht
# stores the submitted manifest in a varchar(16384), so a manifest over 16 KiB


@@ 24,8 25,10 @@ secrets:
  # same pair the bencher and ci-cacher builds use.
  - 7dde4219-0783-4581-a67d-c94749de3600   # ~/.s3-cache-key-id
  - 0e5b3530-6f19-4f30-9b73-9339dd382e46   # ~/.s3-cache-key-secret
  # A tokens.sr.ht working token carrying artifacts:upload, the same secret the
  # sibling services mount. It is what publish_artifacts sends.
  # A tokens.sr.ht working token, the same secret the sibling services mount.
  # It must carry artifacts:upload (publish_artifacts), cov:upload (coverage)
  # and bench:upload (bench); missing one fails that upload and no other.
  # See docs/ci.md#secrets.
  - c7968415-1a6d-4ca0-a188-150fb7f57b65   # ~/.srht-token
sources:
  - https://git.srht.bigb.es/~bigbes/sr-ht-compare


@@ 44,6 47,18 @@ environment:
  # submodule commit core.sr.ht pins at that tag. See docs/ci.md#environment.
  CORE_VER: "0.84.5"
  BOOTSTRAP_REV: 779ad9f174ea5ab7e755f6df0ec9e5912d67dd16
  # Where this build reports on itself. Both repository names are the one on the
  # `sources:` line above and not the service's: the daemon was renamed to
  # diff.sr.ht, the git repository was not. docs/ci.md#coverage, #bench.
  COVER_ORIGIN: https://cov.srht.bigb.es
  COVER_REPO: "~bigbes/sr-ht-compare"
  BENCH_ORIGIN: https://bench.srht.bigb.es
  BENCH_REPO: "~bigbes/sr-ht-compare"
# Literal paths relative to $HOME; `artifacts:` has no globbing, which is why
# the apk is not here. See docs/ci.md#artifacts.
artifacts:
  - cover.out
  - bench.txt
submitter:
  git.sr.ht:
    allow-refs:


@@ 151,13 166,13 @@ tasks:
      gofmt -l . | tee /tmp/fmt
      test ! -s /tmp/fmt || { echo "gofmt would change the files above" >&2; exit 1; }
      go vet ./...
      # `make test` and not a bare `go test ./...`: the test command lives in
      # one place. There is no DSN guard and no service to reach — every suite
      # here is hermetic (gitx builds a bare repo with the local git, web is
      # httptest + ecoretest) — so nothing can skip, and a failure is a non-zero
      # exit that fails this task. That is what the APKBUILD's `!check` now
      # rests on. docs/ci.md#test.
      make test
      # `make cover` and not a bare `go test ./...`: the test command lives in
      # one place, and `cover` is `test` with two flags in it. There is no DSN
      # guard and no service to reach — every suite here is hermetic — so
      # nothing can skip, and a failure fails this task. That is what the
      # APKBUILD's `!check` rests on. The profile goes to $HOME, not into the
      # checkout, which an untracked file would stamp "+dirty". docs/ci.md#test.
      make cover COVERPROFILE="$HOME/cover.out"
  - build: |
      cd "$REPO"
      # -d: makedepends come from `packages:` above, so skip abuild's own


@@ 241,3 256,61 @@ tasks:
      # `cacher exists ||` guard is needed. See docs/ci.md#cache_save.
      cacher dir upload "$KEY_MOD" ~/go/pkg/mod
      cacher dir upload "$KEY_GOC" ~/.cache/go-build
  - coverage: |
      # This build's own coverage, to the instance's cov.sr.ht, and nothing
      # after it depends on it. docs/ci.md#coverage.
      cd "$REPO"
      if [ ! -r ~/.srht-token ]; then
        echo "no ~/.srht-token: nothing uploaded; the profile is this build's"
        echo "cover.out artifact"
        exit 0
      fi
      # GIT_REF is absent on a manual submission and ref is optional; key is the
      # idempotency key. BOTH prefixes are stripped — tags build too.
      ref="${GIT_REF#refs/heads/}"
      ref="${ref#refs/tags/}"
      url="$COVER_ORIGIN/api/v1/repos/$COVER_REPO/reports"
      url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL"
      echo "uploading cover.out to $url"
      # set +x so the header never reaches the log; no Content-Type (the service
      # sniffs); --fail-with-body prints the JSON error AND exits non-zero.
      # docs/ci.md#the-request.
      set +x
      curl -sS --fail-with-body -X POST \
        -H "Authorization: Bearer $(cat ~/.srht-token)" \
        --data-binary "@$HOME/cover.out" \
        "$url"
      echo
  - bench: |
      # This build's own benchmarks — the diffing path a request is spent in —
      # to bench.sr.ht. A VM this small measures a shape, not a number.
      # docs/ci.md#bench.
      cd "$REPO"
      # -s keeps the recipe out of the body; a redirect and a cat and NOT
      # `| tee`, which would hand the task tee's exit status and let a failing
      # benchmark pass. $HOME is where artifacts look.
      make -s bench > "$HOME/bench.txt"
      cat "$HOME/bench.txt"
      # `go test -bench` that matches NOTHING prints "ok" and exits 0, and an
      # empty file is valid benchfmt: without these a renamed benchmark uploads
      # nothing and reports success. Nothing here can skip. docs/ci.md#the-greps.
      grep -q '^BenchmarkDiff/' "$HOME/bench.txt"
      grep -q '^BenchmarkCommitPatch' "$HOME/bench.txt"
      grep -q '^BenchmarkCutPatch/' "$HOME/bench.txt"
      if [ ! -r ~/.srht-token ]; then
        echo "no ~/.srht-token: the benchmarks ran and are above; nothing was"
        echo "uploaded. The file is this build's bench.txt artifact."
        exit 0
      fi
      # visibility acts only on the POST that creates $BENCH_REPO.
      ref="${GIT_REF#refs/heads/}"; ref="${ref#refs/tags/}"
      url="$BENCH_ORIGIN/api/v1/repos/$BENCH_REPO/runs"
      url="$url?commit=$(git rev-parse HEAD)&ref=$ref&key=$JOB_ID&job_url=$JOB_URL"
      url="$url&visibility=public"
      echo "uploading bench.txt to $url"
      set +x
      curl -sS --fail-with-body -X POST \
        -H "Authorization: Bearer $(cat ~/.srht-token)" \
        --data-binary "@$HOME/bench.txt" \
        "$url"
      echo

M .gitignore => .gitignore +7 -0
@@ 34,6 34,13 @@ node_modules/
# Transient build artifacts
*.tmp

# What `make cover` and `make bench` write when they are run with the defaults,
# i.e. from a laptop. CI puts both in $HOME instead, outside the checkout — for
# the reason the paths above are listed: an untracked file here is what makes
# the next `go build` stamp its binary "+dirty".
/cover.out
/bench.txt

# Local instance config
/config.ini


M Makefile => Makefile +47 -2
@@ 23,6 23,32 @@ MINIFY?=minify
# what is left, cannot drift apart.
CSS=web/static/main.min.*.css

# TESTFLAGS is the hole `cover` reaches through, so that the suite has exactly
# one spelling: `cover` is `test` with the profile flags in it and nothing else
# changed. Do not add a second `go test ./...` line to this file.
TESTFLAGS?=

# Where `cover` writes the profile. CI passes an absolute path OUTSIDE the
# checkout ($HOME/cover.out): a profile written into the working tree would be
# an untracked file, and Go reads vcs.modified from `git status --porcelain`,
# which counts those — the packaged binary would stamp itself "+dirty".
# The default is gitignored for the same reason.
COVERPROFILE?=cover.out

# BENCH_COUNT is `go test -count` for `bench`, and ten is the family's number:
# bench.sr.ht's confidence interval for a point becomes finite at six
# repetitions and a comparison becomes significant at four, so a -count under
# six uploads points the service can only mark "low n". A laptop that only wants
# to know the benchmarks still run says `make bench BENCH_COUNT=1`.
BENCH_COUNT?=10

# `bench` names its own timeout because it is the one target that can outrun
# go test's 10m default: ten counts of every benchmark, each of which builds a
# real git repository first, on a builds.sr.ht VM that is some multiple slower
# than a laptop. The failure would be `panic: test timed out` over a goroutine
# dump, which reads like a hang in the code rather than an unlucky number here.
BENCH_TIMEOUT?=20m

all: build

# Compile the service into ./comparesrht.


@@ 30,7 56,26 @@ build:
	go build -o $(BIN) ./cmd/$(BIN)

test:
	go test ./...
	go test $(TESTFLAGS) ./...

# The same suite, plus the coverage profile .build.yml uploads to cov.sr.ht.
#
# It goes through `test` rather than repeating the command, so that CI's run and
# a developer's `make test` cannot become two different suites — the only
# difference between them is the two flags below. -covermode=atomic because the
# service is a concurrent HTTP server and the default `set` mode records "this
# statement ran" rather than how often; cov.sr.ht stores the counts.
cover:
	@$(MAKE) test TESTFLAGS="-covermode=atomic -coverprofile=$(COVERPROFILE)"
	go tool cover -func=$(COVERPROFILE) | tail -1

# The benchmarks of the diffing path, as benchfmt on stdout — the format
# .build.yml uploads to bench.srht.bigb.es. -run='^$$' because this is a run
# whose point is the benchmarks and the suite has already run in `test`;
# -benchmem because B/op and allocs/op are half of what a diff benchmark means
# and they cost nothing to collect.
bench:
	go test -run='^$$' -bench=. -benchmem -count=$(BENCH_COUNT) -timeout $(BENCH_TIMEOUT) ./...

# CSS pipeline: sassc -> minify -> content-hashed filename. The running
# service globs web/static/main.min.*.css at startup, so the hash in the name


@@ 152,4 197,4 @@ install-files:
	mkdir -p $(DESTDIR)$(STATICDIR)
	install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*

.PHONY: all build test css check-css bundle run-dev install install-files
.PHONY: all build test cover bench css check-css bundle run-dev install install-files

M README.md => README.md +2 -0
@@ 87,6 87,8 @@ fork carries one production S3 patch on top of upstream.
```sh
make build   # compile ./comparesrht
make test    # go test ./...
make cover   # the same suite plus ./cover.out (what CI uploads to cov.sr.ht)
make bench   # the diffing benchmarks as benchfmt (what CI uploads to bench.sr.ht)
make css     # build the hashed stylesheet (sassc + minify)
make bundle  # rebuild the vendored frontend bundle (esbuild)
```

M docs/ci.md => docs/ci.md +166 -11
@@ 6,10 6,15 @@ submitted at all — the failure is at submission time and reads like nothing in
particular: the branch simply has no CI. Rationale therefore lives here, and the
manifest carries pointers.

That headroom is nearly spent: the manifest is ~15.8 KiB of the 16 KiB a
submission may carry, which is about eight comment lines. A paragraph added
there is a paragraph that has to come out again — write it here instead.

The pipeline is one linear job on `alpine/edge`: install the cache helper,
assemble the shared SCSS, make a throwaway signing key, decide the version,
restore the Go caches, test, package with `abuild`, publish the apk to
`repo.bigb.es/alpine/v3.22/bigbes`, save the caches.
`repo.bigb.es/alpine/v3.22/bigbes`, save the caches, and report on itself to
cov.sr.ht and bench.sr.ht.

It is triggered by a push to the **sourcehut** side. A push to sourcecraft
cannot reach builds.sr.ht; the gitsync mirror is what puts the commit on


@@ 40,7 45,7 @@ Four, all account-level and shared with the sibling services:
| `apk-ci-s3` | `~/.apk-ci.env` | `publish` |
| `7dde4219-…` | `~/.s3-cache-key-id` | `cacher_init` |
| `0e5b3530-…` | `~/.s3-cache-key-secret` | `cacher_init` |
| `c7968415-…` | `~/.srht-token` | `publish_artifacts` |
| `c7968415-…` | `~/.srht-token` | `publish_artifacts`, `coverage`, `bench` |

They are file secrets. Listing them is what turns `publish` and the cache tasks
on; a manual submission that asks for no secrets still runs the interesting part


@@ 49,6 54,16 @@ of the pipeline and stops at `cacher_init`, which is the right place to notice.
`apk-ci-s3` is referenced by name and the other two by UUID, which is only
because that is how they were written in the donor manifests; both forms work.

`~/.srht-token` is a tokens.sr.ht **working token**, minted once for a person and
carrying the grants of every service its builds reach: `artifacts:upload` for
`publish_artifacts`, `cov:upload` for `coverage`, `bench:upload` for `bench`. It
is one secret shared with the sibling pipelines rather than three per-service
ones — that is what centralising issuance buys. Grants are compared **literally**,
so a token minted before cover.sr.ht was renamed carries `cover:upload` and
uploads nothing to cov; re-mint it and replace the secret. A token missing one
grant fails that upload and no other, because each of the three tasks presents
it independently.

## environment

`CORE_VER` **must track the deployment's `SRHT_CORE_VER`**, and `BOOTSTRAP_REV`


@@ 238,14 253,40 @@ it with an appeal to a flag.
its output is to look at the output. The `tee` keeps the offending filenames in
the build log, where they are the entire diagnosis.

`go vet ./...` next, then `make test` — the Makefile, not a bare `go test ./...`,
so that the test command lives in one place.
`go vet ./...` next, then `make cover COVERPROFILE="$HOME/cover.out"` — the
Makefile, not a bare `go test ./...`, so that the test command lives in one
place.

`cover` is not a second suite. It is `test` with two flags reached through
`TESTFLAGS`, so CI's run and a developer's `make test` cannot become two
different commands:

```make
test:
	go test $(TESTFLAGS) ./...

cover:
	@$(MAKE) test TESTFLAGS="-covermode=atomic -coverprofile=$(COVERPROFILE)"
	go tool cover -func=$(COVERPROFILE) | tail -1
```

`-covermode=atomic` because this is a concurrent HTTP server: the default `set`
mode records that a statement ran and not how often, and cov.sr.ht stores the
counts.

The profile is written to **`$HOME`, outside the checkout**. A profile written
into the working tree would be an untracked file, and Go reads `vcs.modified`
from `git status --porcelain`, which counts untracked files — the apk built two
tasks later would stamp itself `+dirty`. The `COVERPROFILE` default (`cover.out`
in the repo root, for a local run) is gitignored for the same reason.

No Postgres, no network and **no guard**. Every suite in this tree is hermetic:
`gitx/fixture_test.go` builds a bare repository with the local `git` binary, and
`web/web_test.go` is `httptest` plus `ecoretest`. Nothing here is gated on an
environment variable, so nothing can skip itself into a green build, and the
only thing this task owes is a non-zero exit when `make test` fails.
only thing this task owes is a non-zero exit when the suite fails. That is also
what makes the uploaded coverage honest: there is no configuration under which
a package silently contributes zero.

That last point is load-bearing for the APKBUILD. `options="!check"` used to say
the tests could not run in the VM — a claim that was never true — and now says


@@ 359,6 400,120 @@ dead code: without `--force`, `cacher dir upload` already skips a key that is
present, so the guard asked the same question twice and its only real effect was
to make a failure of the `exists` call look like a decision not to upload.

## artifacts

Two, both literal paths relative to `$HOME`: `cover.out` and `bench.txt`.

`artifacts:` has no globbing, which is why the apk is still not listed — its
name carries the version and changes every commit. These two do not.

They are declared **because** the two uploads below can fail. An upload that
404s or 401s leaves the build red and the file downloadable, so the report can
be POSTed by hand instead of re-running a fifteen-minute pipeline for it. That
is also the whole content of the "no token" branch in each task: the work was
done, the result is attached, nothing was sent.

## coverage

The profile `test` produced, POSTed to the instance's cov.sr.ht — this service
reporting on itself, to a service it does not otherwise talk to.

```
POST $COVER_ORIGIN/api/v1/repos/$COVER_REPO/reports
     ?commit=<sha>&ref=<ref>&key=$JOB_ID&job_url=$JOB_URL
```

`COVER_REPO` is `~bigbes/sr-ht-compare`, taken from the `sources:` line and not
from the service name: the daemon was renamed to diff.sr.ht, the git repository
was not, and it is the repository a report is filed under.

Near-last in the pipeline and nothing after it depends on it. A cov.sr.ht outage
must not be able to lose an apk that was already built, tested and published.

## bench

`make -s bench` into `$HOME/bench.txt`, then the same shape of POST:

```
POST $BENCH_ORIGIN/api/v1/repos/$BENCH_REPO/runs
     ?commit=<sha>&ref=<ref>&key=$JOB_ID&job_url=$JOB_URL&visibility=public
```

`visibility` acts only on the POST that *creates* `$BENCH_REPO`; it is ignored
afterwards.

`-s` keeps make from echoing the recipe into the body, which is not decoration:
the body is parsed as benchfmt and a `go test …` line at the top of it is not.

A **redirect and a `cat`**, never `| tee`. A pipeline takes the exit status of
its last command, so `make bench | tee bench.txt` would hand this task `tee`'s
status and let a failing benchmark upload its own wreckage as a green run.

### What is measured

`gitx`, the diffing path, which is what a request to this service is spent in:
`BenchmarkDiff` (both the two-dot and three-dot grammars a compare URL can
carry), `BenchmarkDiffStat`, `BenchmarkCommitPatch`, and the two pure functions
underneath them, `BenchmarkMapFilePatches` and `BenchmarkCutPatch`.

They build their own bare repository with the local `git` — 40 files of 120
lines, a branch that diverged, and a head commit that edits, adds, deletes and
renames — because the *test* fixture's whole history is a dozen lines, over
which a benchmark measures opening a repository rather than diffing one.

`BENCH_COUNT` is 10, the family's number: bench.sr.ht's confidence interval for
a point becomes finite at six repetitions and a comparison becomes significant
at four, so a lower count uploads points it can only mark "low n".

`bench` is the one target that names its own `-timeout` (`BENCH_TIMEOUT`, 20m).
Ten counts of every benchmark, each preceded by building a real git repository,
on a VM some multiple slower than a laptop, can outrun `go test`'s 10m default —
and what that looks like is `panic: test timed out` over a goroutine dump, which
reads like a hang in the service rather than an unlucky number in a Makefile.

### The greps

```sh
grep -q '^BenchmarkDiff/' "$HOME/bench.txt"
grep -q '^BenchmarkCommitPatch' "$HOME/bench.txt"
grep -q '^BenchmarkCutPatch/' "$HOME/bench.txt"
```

These are not belt-and-braces. **`go test -bench` that matches nothing prints
`ok` and exits 0**, and an empty file is valid benchfmt — so a renamed, moved or
deleted benchmark would upload nothing at all and the build would go green over
it. The three names span the three shapes in the file (a sub-benchmark, a
top-level one, and the pure-function pair), so a package that stopped compiling
its benchmarks cannot slip through.

Nothing here is gated on an environment variable, so unlike the sibling services
there is no DSN guard to write: no benchmark in this tree can skip itself, and a
short file therefore means something went wrong rather than something was
unavailable.

### The request

Both uploads share the same three rails, and each of them is here because it
failed once somewhere in this family:

- **`set +x` immediately before the `curl`.** builds.sr.ht traces task bodies,
  and `-H "Authorization: Bearer $(cat ~/.srht-token)"` traced is a working
  token in a build log anyone can read.
- **`curl -sS --fail-with-body`.** `--fail` alone throws the body away, and the
  body is the service's JSON explanation; `--fail-with-body` prints it *and*
  still exits non-zero.
- **Both ref prefixes stripped**, `refs/heads/` and `refs/tags/`. This pipeline
  builds tags too, and a tag build would otherwise file its report under a ref
  literally named `refs/tags/v0.3.0`.

`GIT_REF` is absent on a manually submitted build; `ref` is optional for both
APIs, so it goes up empty. `key=$JOB_ID` is the idempotency key: re-running a
job replaces its report rather than adding a second one.

The `[ ! -r ~/.srht-token ]` guard is **not** a fallback for a failed upload.
With the file present the upload is fatal on purpose. It covers exactly one
case — a submission that asked for no secrets — and it says so in the log.

## css

The stylesheet is compiled from `scss/main.scss` against the shared partials and


@@ 437,12 592,12 @@ parallel — the copying would start beside the build it is supposed to follow.

## What is not here

- **No coverage upload.** The sibling services POST their profile to cover.sr.ht
  at the end of the pipeline. Adding it here needs a token secret and a
  repository on cover.
- **No artifacts.** There is nothing to download: the apk goes to S3, and its
  name changes every commit, which `artifacts:` cannot express (it has no
  globbing).
- **No Postgres.** This service has no database and its suites are hermetic by
  design; there is nothing for a `postgres` task to do, and adding one would
  buy an idle daemon in the VM.
- **No apk in `artifacts:`.** Its name carries the version and changes every
  commit, which `artifacts:` cannot express — it has no globbing. The apk's two
  destinations are S3 and artifacts.sr.ht.
- **No matrix.** One architecture, one image.

## Known wart

A gitx/bench_test.go => gitx/bench_test.go +293 -0
@@ 0,0 1,293 @@
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")
				}
			}
		})
	}
}

M gitx/fidelity_test.go => gitx/fidelity_test.go +3 -2
@@ 49,8 49,9 @@ func newFidelityRepo(t *testing.T) (reposRoot, base, head string) {
	return reposRoot, base, head
}

// fxRevIn is fxRev for an arbitrary repo name under fxOwner.
func fxRevIn(t *testing.T, reposRoot, name, rev string) string {
// fxRevIn is fxRev for an arbitrary repo name under fxOwner. Like gitTest it
// takes a testing.TB, because bench_test.go resolves its landmarks with it.
func fxRevIn(t testing.TB, reposRoot, name, rev string) string {
	t.Helper()
	bare := filepath.Join(reposRoot, "~"+fxOwner, name)
	cmd := exec.Command("git", "-C", bare, "rev-parse", rev)

M gitx/fixture_test.go => gitx/fixture_test.go +4 -3
@@ 31,8 31,9 @@ const (
)

// gitTest runs git in dir with a hardened, deterministic environment, failing
// the test on error.
func gitTest(t *testing.T, dir string, date string, args ...string) string {
// the test on error. It takes a testing.TB rather than a *testing.T because
// bench_test.go builds its own fixture repository with the same helper.
func gitTest(t testing.TB, dir string, date string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir


@@ 55,7 56,7 @@ func gitTest(t *testing.T, dir string, date string, args ...string) string {
	return string(out)
}

func writeFile(t *testing.T, dir, name string, data []byte) {
func writeFile(t testing.TB, dir, name string, data []byte) {
	t.Helper()
	p := filepath.Join(dir, name)
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {