gocove

Branch and condition coverage for Go, layered on top of go test -cover.
gocove extends Go's built-in line coverage with branch and condition coverage. Where go test -cover tells you a line ran, gocove tells you which branch of an if, which case of a switch, and which side of a && or || was actually exercised by your tests.
Status
Beta — feature-complete; hardening for v1. The instrumentation pipeline is complete and validated; reports cover condition, block, and branch coverage in text, JSON, and HTML (4a/4c/4d); runs merge across CI shards (4b); --base=REF filters reports to lines changed in a PR (4e); and gocove gate enforces coverage thresholds in CI (4f). The current release line is v0.1.0-beta.* — usable today on Linux/macOS Go ≥ 1.23 module-mode projects; see Limitations for known caveats (Windows not supported; panic-then-os.Exit without cov.* loses observations). See CHANGELOG.md for the per-slice history.
| Sub-project |
Scope |
Status |
| 1 |
Foundation: runtime + .gcv/.gcmeta formats |
✅ Shipped |
| 2a |
Cover toolexec interception, catalog discovery |
✅ Shipped |
| 2b |
AST instrumentation passes |
✅ Shipped |
| 2c |
TestMain wrapping + Flush wiring |
✅ Shipped |
| 2d |
User-facing cov helper + diagnose pass |
✅ Shipped |
| 2e |
Implicit runtime injection (importcfg interception) |
✅ Shipped |
| 4a |
Condition coverage report (text) |
✅ Shipped |
| 4b |
Merge: combine multiple meta-dirs |
✅ Shipped |
| 4c |
Block + branch coverage in text report |
✅ Shipped |
| 4d |
JSON + HTML renderers (`--format=json |
html`) |
| 4e |
Diff-aware reporting (--base=REF) |
✅ Shipped |
| 4f |
Gate (threshold-based exit code) |
✅ Shipped |
| 4g |
Exclude patterns (--exclude-pkg, --exclude-file) |
✅ Shipped |
See docs/superpowers/specs/ for full design.
How it works
gocove hooks the Go toolchain via -toolexec. When you run gocove test, gocove:
- Intercepts
cmd/cover invocations, layering its own instrumentation on top of the cover-rewritten source.
- Wraps the auto-generated
_testmain.go so coverage observations flush to disk before os.Exit.
- Emits
.gcmeta (catalog) and .gcv (observations) files under $GOCOVE_META_DIR.
The runtime is small (~150 LOC). The toolexec shim is reversible — without gocove test, your tests run normally.
Install
go install github.com/srvgit/gocove/cmd/gocove@latest
Requires Go 1.23 or later.
go install does two things: it builds the gocove binary into $GOBIN, and it leaves the gocove source in $GOMODCACHE. Both matter — gocove doesn't embed its runtime in the binary; at run time it locates its own version via debug.BuildInfo and reads the runtime source from $GOMODCACHE/github.com/srvgit/gocove@<version>/... to build the toolexec trampoline. A populated, writable module cache is part of the install contract, not a transient download artifact.
Installation through a proxy
Default public proxy (no setup needed). Go's default GOPROXY=https://proxy.golang.org,direct lazy-fetches public GitHub modules on first request — the go install command above just works.
Corporate proxy that mirrors public modules (Athens, JFrog Artifactory, internal Go module proxies):
export GOPROXY=https://goproxy.corp.example.com,direct
go install github.com/srvgit/gocove/cmd/gocove@latest
If your proxy is allow-list based, ask your admin to whitelist github.com/srvgit/gocove.
Internal fork on a private host (git.corp/team/gocove):
export GOPRIVATE=git.corp/*
export GOSUMDB=off # or point at an internal checksum DB
go install git.corp/team/gocove/cmd/gocove@latest
GOPRIVATE skips the public proxy and the public checksum database for matched paths. Make sure git credentials (SSH key or ~/.netrc) are set so go get can clone it.
Pinning a specific version (recommended for CI):
go install github.com/srvgit/gocove/cmd/gocove@<sha-or-tag>
Until tagged releases ship, @latest resolves to a pseudo-version of main. Pin a SHA in CI for reproducibility.
Install from source (git clone + build)
Useful when you want bleeding-edge main, when go install can't reach the proxy, or when developing on gocove itself.
git clone https://github.com/srvgit/gocove.git
cd gocove
# Install from the local checkout into $GOBIN (or $GOPATH/bin, or $HOME/go/bin
# if neither is set). Make sure that directory is on your $PATH.
go install ./cmd/gocove
# Verify it built — shows "gocove dev (built with go1.23.x or later)".
gocove version
If gocove isn't found after install, run go env GOBIN GOPATH and add the resolved bin directory to your $PATH.
One important env var when running a from-source build: because the binary was compiled from a working tree (no proxy-supplied module info), gocove can't find its runtime sources from $GOMODCACHE. Point it at your checkout:
export GOCOVE_DEV_SOURCE_DIR=/abs/path/to/gocove # the directory you cloned into
Add the export to your shell profile if you'll keep using the from-source build, or set it in your CI job's environment. Without it, gocove test exits with gocove not resolvable from buildinfo; set GOCOVE_DEV_SOURCE_DIR — that's the signal the trampoline can't locate the runtime source.
Smoke-test:
cd examples/demo
GOCOVE_META_DIR=$PWD/.gocove gocove test ./...
gocove report --meta-dir=$GOCOVE_META_DIR
A go install-ed binary can find its sources without GOCOVE_DEV_SOURCE_DIR because the proxy stores them in $GOMODCACHE under the resolved version; only from-source builds need the override.
Offline / GOPROXY=off — same as "Install from source" above. The clone gives you everything; you don't need network at install time once the repo is on disk.
Note on the cov helper: the basic gocove test ./... flow doesn't need any go.mod change — the runtime is injected via toolexec. But if you import github.com/srvgit/gocove/cov for custom-TestMain packages, your project's go.mod needs require github.com/srvgit/gocove, and that fetch also goes through your GOPROXY. Same allow-listing rules apply.
Try it
A self-contained demo lives at examples/demo/. It's a tiny Go module with deliberately uneven test coverage; its README walks every gocove feature (test, report text/JSON/HTML, merge, gate, diff-aware --base) end-to-end with copy-paste commands.
Usage
Basic — packages without TestMain
gocove test ./...
That's it — no go.mod change required. gocove runs your tests through the normal go test -cover path with extra instrumentation, injecting the runtime archive into compile/link/vet via toolexec. Output:
ok example.com/foo 0.012s coverage: 87.4% of statements
gocove: meta dir = /var/folders/.../gocove-meta-XXX
.gcv and .gcmeta files appear under the printed meta dir.
Packages with custom TestMain
If your package has a custom TestMain that calls os.Exit directly, gocove cannot automatically flush observations before exit. Import the cov helper and use one of three patterns. Using cov.* requires require github.com/srvgit/gocove in your go.mod — unlike the basic gocove test ./... flow, cov is referenced in your _test.go source and go list resolves it before any toolexec runs.
import "github.com/srvgit/gocove/cov"
// 1. Drop-in for os.Exit(m.Run())
func TestMain(m *testing.M) { cov.Run(m) }
// 2. When passing m.Run to a wrapper (goleak.VerifyTestMain, etc.)
func TestMain(m *testing.M) {
framework.EtcdMain(cov.Wrap(m.Run))
}
// 3. Manual control
func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
cov.Flush()
os.Exit(code)
}
If gocove test detects an unhelped TestMain, it emits a build-time warning naming the file and listing these three patterns. The warning is suppressible via --no-warn or GOCOVE_NO_WARN=1.
The cov helpers compile and link normally outside gocove test (they no-op when $GOCOVE_META_DIR is unset), so they're safe to leave in your test files permanently.
Reading observations
export GOCOVE_META_DIR=$PWD/.gocove
gocove test ./...
gocove report --meta-dir=$GOCOVE_META_DIR
Output is a per-package, per-condition table augmented with block + branch coverage:
gocove report
meta-dir: /path/to/.gocove
packages: 1 files: 4 conditions: 23
example.com/foo (cond: 23, hit: 18, full: 2, cond%: 8.7%, block%: 50.0%, branch%: 42.1%)
shapes.go
[T+F ] 42:14 i < n (block ✓, branch ✓)
[T only ] 50:23 sum < 1000 (block ✓, branch ✓)
[F only ] 58:7 x < 0 (block ✓, branch ✗)
[(never)] 62:7 x > 0 (block ✓, branch ✗)
...
TOTAL cond: 23 hit: 18 full: 2 cond%: 8.7% block%: 50.0% branch%: 42.1%
cond% is the strict definition: conditions where both branches were exercised. block% is the percentage of structural blocks (if-bodies, function bodies, etc.) that were entered at least once — sourced from Go's native -coverprofile= output, which gocove test now captures automatically into $GOCOVE_META_DIR/_gocove/cover.profile. branch% is the percentage of decision points where every recorded arm was taken at least once.
Note on branch% — for if x { ... } without an else, gocove records only the then-arm as a branch. A single hit therefore marks that decision point fully-branched, even though the test never exercised the false path. This lower-bounds true branch coverage; cond% remains the strict, complete metric. See spec 4c §10 risk 4 for the workaround a future slice will adopt.
If you have your own -coverprofile= flag in gocove test, gocove keeps your path; gocove report will fall back to skipping block/branch coverage if it can't find the file at the expected location.
Combining runs from multiple test invocations
GOCOVE_META_DIR=$PWD/.gocove-shard1 gocove test ./pkgA/...
GOCOVE_META_DIR=$PWD/.gocove-shard2 gocove test ./pkgB/...
gocove merge --in=$PWD/.gocove-shard1 --in=$PWD/.gocove-shard2 --out=$PWD/.gocove-merged
gocove report --meta-dir=$PWD/.gocove-merged
gocove merge validates that catalogs across inputs share the same fingerprint per import path — if your source has drifted between runs, the merge fails loudly rather than producing nonsense. --force overwrites --out if it already exists; merge refuses to write into a directory that overlaps any --in.
JSON and HTML output
gocove report --meta-dir=$GOCOVE_META_DIR --format=json > coverage.json
gocove report --meta-dir=$GOCOVE_META_DIR --format=html --output=coverage.html
open coverage.html
--format=json emits a stable, versioned schema (schema_version: "4d.1") for CI dashboards and tooling. --format=html emits a single self-contained page with no external CSS/JS — drop it into a build artifact and view it offline. --output=PATH overwrites the file if it exists (matching gofmt -w and go test -coverprofile=). The default --format=text is unchanged.
Diff-aware reporting (PR coverage)
gocove report --meta-dir=$GOCOVE_META_DIR --base=origin/main
--base=REF filters the report to conditions, blocks, and branches whose source lines were added or modified in HEAD vs REF. The diff is computed as git diff REF...HEAD (three-dot — same view GitHub's PR diff shows), so --base=origin/main answers "what fraction of the lines this PR changed are covered?" Works with all three formats. Add --repo=DIR if gocove is run from outside the repository.
For every realistic scenario — PR coverage, release-to-release reports, branch-to-branch comparisons, between-deploy audits, last-N-commits delta, what counts as "good" coverage on new code, and CI patterns for each — see the diff-aware coverage guide.
Enforcing coverage thresholds in CI
gocove gate --meta-dir=$GOCOVE_META_DIR --min-cond=80 --min-block=85
gocove gate --meta-dir=$GOCOVE_META_DIR --base=origin/main --min-cond=100 # PR coverage gate
gocove gate exits 0 if every configured --min-* is met, 1 if any fails. Each --min-* is a percent in [0, 100]; the comparison uses the displayed (one-decimal-rounded) percentage, so a metric printed as 80.0% always passes --min=80. Combine with --base=REF for diff-aware gates that demand high coverage on changed lines without fighting against historical coverage debt. --format=json emits a schema_version: "4f.1" JSON document for CI step parsing. Note: in absolute mode (no --base), an empty meta-dir with any gate set fails — silent-pass on missing observations would defeat the purpose.
Excluding packages and files
gocove report and gocove gate accept --exclude-pkg=PATTERN and --exclude-file=PATTERN to drop generated, vendored, or otherwise uninteresting code from the report and from gate threshold checks. Both flags are repeatable and accept comma-separated values, so all of these forms work:
# Drop a whole package subtree.
gocove report --meta-dir=$GOCOVE_META_DIR --exclude-pkg=example.com/internal/generated/...
# Drop multiple packages, repeated flag.
gocove gate --meta-dir=$GOCOVE_META_DIR --min-cond=80 \
--exclude-pkg=example.com/internal/legacy \
--exclude-pkg=example.com/internal/generated/...
# Drop multiple packages, comma-separated.
gocove report --meta-dir=$GOCOVE_META_DIR \
--exclude-pkg=example.com/internal/legacy,example.com/internal/generated/...
# Drop generated files by basename glob (matches at any depth).
gocove report --meta-dir=$GOCOVE_META_DIR --exclude-file='*_generated.go'
# Drop one specific file in one specific package.
gocove report --meta-dir=$GOCOVE_META_DIR \
--exclude-file=example.com/internal/db/schema.go
Pattern syntax (Go-idiomatic, no extra dependencies):
| Pattern |
Means |
example.com/foo |
exact import path match |
example.com/foo/... |
this package and every subdirectory (Go's standard ... syntax) |
example.com/*/generated |
one path segment, name "generated" (* does not cross /) |
*_generated.go |
any file whose basename matches (no / in pattern → basename match at any depth) |
example.com/internal/db/schema.go |
exact qualified file path |
example.com/internal/*/swagger.go |
qualified path with one-segment glob |
Totals are recomputed over the surviving set. Excluded packages and files leave both numerator and denominator — if a generated file had 60 conditions with 0 fully-covered, excluding it raises cond% by removing the dead weight from both sides. A summary line goes to stderr (report: excluded N package(s), M file(s) by --exclude-pkg/--exclude-file) so a typo'd glob doesn't silently erase your report.
Composes with --base=REF. The diff filter runs first, then exclusions. So --base=origin/main --exclude-file='*_generated.go' --min-cond=80 means "of the non-generated lines this PR changed, at least 80% must be fully condition-covered."
Exclusion is render-time only. gocove test always instruments and records observations for every package — exclusions are applied at report and gate time. This keeps cross-package coverage signal intact (no surprising blind spots) and lets you re-render the same .gcv data with different exclusion sets without rerunning tests.
The internal/gcvfile package's Read(io.Reader) (*File, error) is still available for programmatic access.
Development
git clone https://github.com/srvgit/gocove.git
cd gocove
go test ./... -race
The repository follows a spec-first, plan-driven workflow. See CONTRIBUTING.md for the full process. Architecture and design notes are under docs/superpowers/specs/; detailed implementation plans are under docs/superpowers/plans/.
Limitations
- Custom
TestMain requires the opt-in dep — the basic gocove test ./... flow works without any go.mod change, but if you import github.com/srvgit/gocove/cov in your test source you must add require github.com/srvgit/gocove because go list resolves test imports before any toolexec runs. See docs/superpowers/specs/2026-04-26-gocove-2e-design.md §11.
- Wrapper-delegation TestMains — when your
TestMain calls a third-party helper that takes *testing.M (e.g., goleak.VerifyTestMain(m)) rather than m.Run, you may need to restructure the TestMain to use cov.Wrap(m.Run) or call cov.Flush() manually. See docs/testmain.md (planned) for known wrappers.
- Panic safety —
cov.Wrap defers Flush, so observations gathered before a panic in m.Run reach disk. Direct os.Exit(m.Run()) users (no cov.* opt-in) lose observations on panic.
- Windows — currently Unix-only. Path-separator assumptions in tests need fixing for Windows CI.
Contributing
Contributions welcome. Please read CONTRIBUTING.md before opening a PR — the project uses a spec → plan → subagent-driven implementation flow that is non-standard but produces high-quality, well-reviewed changes.
For bugs and feature requests, open an issue. For security disclosures, see SECURITY.md.
License
MIT. See LICENSE.