Go coverage tool that runs tests only on packages you changed and validates thresholds — with a modern HTML report.
go install github.com/the-hotels-network/covlens/cmd/covlens@latest
covlens # run from any Go repo; checks diff + total coverage, opens HTML report- Diff coverage — measures only the lines you changed, not the whole file
- Total coverage — validates an overall floor for the whole project
- HTML report — self-contained, dark mode, syntax-highlighted, jump-to-uncovered
- JSON sidecar — machine-readable output for CI tooling (no HTML scraping needed)
- Exits 1 when a threshold fails — CI-friendly out of the box
- What it does
- Install
- Usage
- Configuration
- Ignoring files and functions
- Output
- CI example (CircleCI)
- Multi-module repos
- Test selection
- How diff coverage is calculated
- Troubleshooting
- License
- Finds all
.gofiles changed relative to your base branch (committed, staged, and untracked) - Runs
go test -short -coverprofileon those packages (tests guarded bytesting.Short()are skipped — see Test selection) - Computes coverage for the changed lines only (not the whole file)
- Validates two thresholds and exits 1 if either fails — CI-friendly
- Generates a self-contained HTML report with syntax highlighting, a file sidebar, dark mode, and jump-to-uncovered
- Writes a machine-readable
coverage_report.jsonsidecar so CI tooling can parse the result without scraping the HTML or stderr
go install github.com/the-hotels-network/covlens/cmd/covlens@latestRequires Go 1.25+, git, and go on your PATH. (Go's auto-toolchain feature means
go install from an older Go version will automatically download the 1.25 toolchain.)
Heads-up: Go's auto-toolchain downloads omit covdata, which go test -coverprofile needs. If your system Go is older than your project's go.mod go directive, you'll see a "missing covdata" error. Install a matching system Go (from go.dev/dl/ or brew install go) so auto-toolchain doesn't trigger.
# Run from inside any Go repository
covlens
# Override thresholds
covlens --diff-threshold 90 --total-threshold 75
# Compare against a different base branch
covlens --base-branch develop
# Don't open the report automatically
covlens --no-open
# Scan the whole project — no diff, every file is measured
covlens --full
# Use a custom config file
covlens --config path/to/covlens.yaml| Flag | Default | Description |
|---|---|---|
--base-branch |
main |
Branch to diff against |
--diff-threshold |
80 |
Minimum coverage % for changed lines |
--total-threshold |
70 |
Minimum coverage % for the whole project |
--output-dir |
.coverage |
Directory for profiles, HTML report, and JSON sidecar |
--no-open |
false | Skip auto-opening the report in the browser |
--open |
false | Force opening the report in the browser (overrides html.auto_open: false in config) |
--ratchet (-r) |
false | Replace --total-threshold with a "must not drop vs. base branch" check |
--full (-f) |
false | Skip the diff and report coverage for every file in the project |
--verbose (-v) |
false | Stream go test output to stdout (default: capture to .coverage/test_output.log) |
--config |
covlens.yaml |
Path to config file |
Place a covlens.yaml at your project root. CLI flags override it.
See example/covlens.yaml for a fully annotated reference.
Add a //covlens:ignore comment before the package declaration:
//covlens:ignore — generated by protoc, do not edit
package mypkgAdd a //covlens:ignore doc comment directly above the function:
//covlens:ignore — wires up the HTTP server, covered by e2e tests
func main() {
...
}Every run writes its artifacts under output_dir (default: .coverage/):
coverage_report.html— self-contained HTML reportcoverage_report.json— machine-readable sidecartest_output.log— rawgo testoutput; written when--verboseis not set (use--verboseto stream it to stdout instead)coverage.out,coverage_diff.out— raw Go coverage profiles (kept for re-use; safe to delete)
The JSON sidecar is the integration point for CI tooling. Key fields:
{
"schema": "1",
"mode": "diff",
"diff": {
"status": "measured",
"coverage": 92.5,
"threshold": 80,
"passed": true
},
"totalCoverage": 78.4,
"totalThreshold": 70,
"totalPassed": true,
"htmlReportPath": "/abs/path/to/coverage_report.html",
"files": [
{ "path": "pkg/foo.go", "coverage": 92.5, "status": "ok", "statements": 12, "covered": 11 }
]
}diff is omitted in --full mode (no diff is computed). diff.status is one of "measured", "no-go-changes", "only-deletions", or "all-excluded" — the non-measured values indicate a vacuous pass (diff.passed: true but no measurement is meaningful).
schema is bumped on breaking changes; new fields are additive. The full type lives in internal/printer/json.
version: 2.1
jobs:
coverage:
docker:
- image: cimg/go:1.25
steps:
- checkout
- run:
name: Coverage check
command: |
go install github.com/the-hotels-network/covlens/cmd/covlens@latest
covlens --no-open # add --verbose to stream test output to the job log
- store_artifacts:
path: .coverage/ # captures HTML report, JSON sidecar, and test_output.log
destination: coverage
workflows:
test:
jobs: [coverage]The process exits 1 when a threshold is not met, which fails the job. store_artifacts
makes the HTML report and JSON sidecar available in the CircleCI UI under the build's
Artifacts tab.
The JSON sidecar is the integration point for CI tooling — see Output. Reference scripts that wire covlens into specific CI/chat platforms live under scripts/ci/. For CircleCI + GitHub, scripts/ci/circleci/pr-comment.sh posts a sticky PR comment with the pass/fail summary and a link to the HTML report — see its README. These scripts are samples, not part of the binary's stability contract; see ADR 0003.
covlens walks up the directory tree from each changed file to find the nearest go.mod, so monorepos with multiple modules work without any configuration.
covlens always passes -short to go test. Tests that opt in via if testing.Short() { t.Skip() } — typically slow integration or end-to-end suites — are skipped, and therefore do not contribute to coverage. Keep this in mind when calibrating thresholds: the coverage number reflects what your fast suite protects, not your full test matrix.
To run everything (including -short-gated tests), invoke go test ./... directly instead of going through covlens.
For a cleaner separation, put your end-to-end tests in a dedicated sub-package and short-circuit the whole package with TestMain instead of sprinkling t.Skip() calls across each test:
// internal/myapp/e2e/main_test.go
package e2e
import (
"flag"
"os"
"testing"
)
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(0)
}
os.Exit(m.Run())
}Every test in the e2e package now skips under -short automatically — new e2e tests need no boilerplate. To run them, invoke go test ./... from your repo root (covlens itself uses this layout; see internal/covlens/e2e/).
If your e2e-heavy files have no meaningful unit-test seam without a deeper refactor, list them in exclude_files so they don't drag the total coverage number down — see Ignoring files and functions.
covlens does not pass -race to go test. The race detector requires CGO_ENABLED=1 and a C toolchain, which is absent from minimal CI images (golang:alpine, distroless, scratch-based), and it multiplies test wall time by 2–5×. Keeping it opt-in preserves portability and keeps covlens focused on coverage.
To enable race detection for a covlens run, set GOFLAGS in your environment:
GOFLAGS="-race" covlensThe subprocess inherits the environment, so the flag is appended to every go test invocation covlens makes. -covermode=atomic (which -race requires) is already set.
For each changed file, covlens:
- Gets the changed line ranges via
git diff - Intersects those ranges with the coverage profile blocks
- Sums only the statements that fall within changed lines
This means: if you added 20 lines and all 20 are covered, diff coverage is 100% even if the rest of the file is untested.
You'll see this when your system Go is older than the version your project requires (e.g. system go1.25.10, but go.mod/go.work declares go 1.26):
# sync/atomic
compile: version "go1.26.0" does not match go tool version "go1.25.10"
It surfaces under covlens but not under a plain go test, because it's triggered specifically by coverage instrumentation. With GOTOOLCHAIN=auto, your system go re-execs into the newer downloaded toolchain — but the covdata step invokes a bare go resolved from PATH, which lands on your older system binary while pointed at the newer toolchain's files. That driver/toolchain mismatch is the error. This is an open Go bug: golang/go#77820.
Fix — pick one:
- Set
GOTOOLCHAINto the version your project requires, so everygoinvocation (including the nested one) uses it:GOTOOLCHAIN=go1.26.0 covlens
- Or upgrade your system Go to match the project.
go clean -cache does not help, and GOTOOLCHAIN=local fails outright when the project requires a newer Go than you have installed.
Built with Claude Code