Skip to content

Linting & Formatting

What you will learn: how a production Go codebase enforces formatting and static analysis — not as a make chore you remember to run, but as two independent layers (local git hooks and CI) wired around gofumpt, goimports, golangci-lint, protolint, and codespell. Multigres is the running example; the patterns are general.

The first surprise, if you come from other ecosystems, is what’s missing. There is no make lint and no make fmt target here. Formatting and linting aren’t on-demand chores — they’re enforced by two separate gates:

  1. Local git pre-commit hooks auto-format your staged files (and re-stage them), then run golangci-lint on the changed packages — all before the commit lands. They’re installed once, by make tools.

  2. CI gates run a pinned golangci-lint action, a super-linter pass for non-Go files, a job that checks generated code is fresh, and a Conventional-Commits PR-title check.

The two layers use overlapping-but-not-identical tooling and versions, so a clean local commit can still fail CI. This page walks both, file by file, so you know exactly what each gate checks and when to run it.


The big picture: two layers, no Makefile target

Section titled “The big picture: two layers, no Makefile target”

Editing a .go, .proto, or .yml file kicks off a chain. The local pre-commit hook runs a series of small executables in lexical order; the auto-format ones re-stage your files, the lint ones fail the commit. Once the commit lands and you push, GitHub Actions runs its own, broader set of checks.

Two gates: local hooks and CI
Rendering diagram…

The hooks are installed by make tools, which symlinks them into the repo’s git hooks directory:

Makefile
tools: ## Install protobuf and build tools.
mkdir -p "$$(git rev-parse --git-dir)/hooks"
ln -sf "$(MTROOT)/misc/git/pre-commit" "$$(git rev-parse --git-dir)/hooks/pre-commit"
ln -sf "$(MTROOT)/misc/git/commit-msg" "$$(git rev-parse --git-dir)/hooks/commit-msg"
./tools/setup_build_tools.sh

The mapping from “what you changed” to “what catches it” is worth keeping in your head:

You changed…Local gate (after make tools)Check it manuallyCI job
.go filesgoimports, gofumpt, golangci-lint, check-license hooksgo tool goimports -l ./..., go tool gofumpt -l ./..., golangci-lint run ./...golangci-lint.yml
.proto filesprotolint hook (auto -fix)go tool protolint <file>.protosuper-linter
Generated files (go/pb/, parser, AST)not formatted by hooksmake build-all && go mod tidy && git statusvalidate-generated-files
.sh / .yml / .md / .css / .tsshfmt, prettier hooks (auto-skip if tool missing)tools/run_super_linter.sh (Docker)super-linter
Spelling (any text)— (codespell is CI-only)tools/run_super_linter.shsuper-linter
Commit / PR titlecommit-msg hooklint-pr-title.yml

Where the tools come from: go tool, not go install

Section titled “Where the tools come from: go tool, not go install”

Go 1.24+ lets a module register executable dependencies in go.mod under a tool (...) block; you then run them with go tool <name> — no separate install step. This codebase uses that for most of its lint and format tooling:

go.mod
tool (
github.com/mfridman/tparse
github.com/quasilyte/go-ruleguard/cmd/ruleguard
github.com/wadey/gocovmerge
github.com/yoheimuta/protolint/cmd/protolint
golang.org/x/tools/cmd/goimports
google.golang.org/grpc/cmd/protoc-gen-go-grpc
google.golang.org/protobuf/cmd/protoc-gen-go
mvdan.cc/gofumpt
)

So gofumpt, goimports, protolint, and ruleguard are all available via go tool with no install — which is exactly how the hooks invoke them. See Modules, Deps & Codegen for the tool directive mechanics.


The pre-commit script itself is intentionally tiny — it runs every executable in misc/hooks/ in lexical order and stops on the first failure:

misc/git/pre-commit
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
for hook in "$REPO_ROOT"/misc/hooks/*; do
if [ -x "$hook" ]; then
$hook
fi
done

Lexical order matters: 01-goimports and 02-gofumpt run first, so code is formatted before golangci-lint inspects it. Here is what each one does.

01-goimports and 02-gofumpt — format and re-stage

Section titled “01-goimports and 02-gofumpt — format and re-stage”

Both operate only on staged .go files and skip generated protobuf under go/pb/. They format in place and re-add the file to the index, so the formatted version is what gets committed:

misc/hooks/01-goimports
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' | grep -v '^go/pb/')
unformatted=$(go tool goimports -l $gofiles 2>&1)
# ... go tool goimports -w "$f"; git add "$f"

02-gofumpt has the same shape with go tool gofumpt. The division of labour: gofumpt is a stricter superset of gofmt, and goimports additionally manages the import block. The import grouping is governed by a local-prefixes setting, so first-party imports under github.com/multigres group separately from third-party ones. That setting lives in .golangci.yml, not in the hook — see the formatters section below. Stated plainly, the policy is: gofumpt + goimports, with a local prefix of `github.com/multigres`.

check-license — an Apache header on every file

Section titled “check-license — an Apache header on every file”

Enforces a single-line Apache-2.0 copyright header, auto-adding it via addlicense and bumping the copyright year on new files. A new file without a header gets one added and re-staged.

golangci-lint — the big static-analysis gate

Section titled “golangci-lint — the big static-analysis gate”

This is the only hook that does not use go tool. It pins a minimum version and self-installs if missing:

misc/hooks/golangci-lint
REQUIRED_VERSION="v2.6.0"
if ! command -v golangci-lint >/dev/null 2>&1; then
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$REQUIRED_VERSION
fi
golangci-lint config verify # catch invalid config keys that `run` would silently ignore
golangci-lint run $gopackages # only the packages containing changed files

Two things to note. config verify validates .golangci.yml against the schema, so a misspelled key is caught rather than silently ignored. And run is scoped to just the directories of your staged files — fast, but it means a change can pass locally yet break a different package that imports it. CI runs the whole tree.

  • protolint runs go tool protolint -fix on staged .proto files, then re-checks. Config in .protolint.yaml (below).
  • prettier formats yml/yaml/md/mdx/html/css/ts/tsx — but only if prettier is installed; otherwise it silently skips.
  • shfmt formats shell files — likewise silently skips if shfmt isn’t installed.

This is a golangci-lint v2 config (version: "2"), which splits linters and formatters into separate top-level blocks. The run sets a 10-minute timeout and, critically, a ruleguard build tag:

.golangci.yml
version: "2"
run:
timeout: 10m
build-tags:
- ruleguard # required for the custom gocritic ruleguard rules to compile/run

linters.default: none means nothing runs unless explicitly enabled. The enabled set covers the usual correctness checks plus several leak- and performance-oriented ones:

bodyclose · copyloopvar · depguard · errcheck · errorlint · forbidigo · gocritic
gosec · govet · ineffassign · mirror · misspell · modernize · nilerr · nolintlint
nosprintfhostport · perfsprint · sloglint · sqlclosecheck · staticcheck · unconvert
unused · whitespace

modernize is on — it suggests Go-version-appropriate idioms — and sloglint is set to context: scope, meaning structured logging must carry a context where one is in scope. See stdlib & idioms and context.

.golangci.yml
forbidigo:
forbid:
- pattern: ^os\.Exit$
msg: "os.Exit() prevents cleanup hooks from firing. Return an error instead. Only allowed in main() or TestMain() with //nolint:forbidigo. In init(), use panic() instead."

os.Exit terminates the process immediately, so deferred cleanup — closing pools, flushing telemetry, releasing locks — never runs. The Go-idiomatic fix is to return an error up to main and let main decide. The rare legitimate exits (main(), TestMain()) are allowed with an inline //nolint:forbidigo. This dovetails with the error-handling philosophy in errors.

depguard — architectural isolation as a lint rule

Section titled “depguard — architectural isolation as a lint rule”

This is the most codebase-specific part, and the most instructive. depguard encodes architectural dependency rules — services can’t depend on cmd/ or on each other, tools/ can’t depend on repo code, the parser must stand alone — as mechanical checks. A few of the rules:

  • tools-isolation — files under go/tools/** may import only the standard library and other go/tools packages. Generic helpers stay generic.
  • parser-isolationgo/common/parser/** may depend only on itself within go/common, so the parser is usable as a standalone library.
  • per-service isolation — each service (multiadmin, pgctld, multigateway, multiorch, multipooler) enforces that it “can only be imported by its own package and cmd.”

The per-service rules use an inverted-glob trick worth understanding (the multipooler rule shown):

.golangci.yml
multipooler-isolation:
files:
- "!**/go/services/multipooler/*.go"
- "!**/go/services/multipooler/**/*.go"
- "!**/go/cmd/multipooler/*.go"
- "!**/go/cmd/multipooler/**/*.go"
- "**/*.go"
list-mode: lax
deny:
- pkg: "github.com/multigres/multigres/go/services/multipooler"
desc: "multipooler can only be imported by its own package and cmd"

Read it as: apply this rule to every .go file except multipooler’s own service and cmd packages; in those files, deny importing the multipooler service. The leading ! negations carve out the legal importers; the positive **/*.go glob is everything else; the deny then blocks the cross-service import. This is how the architecture rule “services cannot depend on other services” (see architecture & request flow) becomes enforceable.

One more depguard rule bans an entire package outright:

.golangci.yml
use_modern_packages:
list-mode: lax
deny:
- pkg: "math/rand$"
desc: "Please use math/rand/v2"

gocritic → ruleguard: a custom rule engine

Section titled “gocritic → ruleguard: a custom rule engine”

gocritic has every default check disabled and only ruleguard enabled:

.golangci.yml
gocritic:
disable-all: true
enabled-checks:
- ruleguard
settings:
ruleguard:
rules: "go/tools/ruleguard/rules.go"

The rules themselves are written in the go-ruleguard DSL — Go code guarded by //go:build ruleguard in package gorules. That build tag is exactly why the config sets run.build-tags: [ruleguard]; without it the rules wouldn’t compile into the lint run. Editing one file, go/tools/ruleguard/rules.go, changes lint behaviour repo-wide.

What makes this powerful is what the rules encode: concrete, project-specific invariants a human reviewer would otherwise have to remember.

RuleWhat it enforces
disallowUnderscoreInFlagsflag names use dashes, not underscores
requireGrpcCommonNewClientuse grpccommon.NewClient(), not grpc.NewClient(), so telemetry is attached
disallowDirectExecCommandContext / disallowDirectProcessTerminationuse the process wrapper, not raw exec.CommandContext or signals
requireContextBackgroundJustificationcontext.Background() needs a justification; prefer TODO() or an explicit detach
disallowOtelMeterOutsideMetricsFiles / disallowMetricsConstructorArgsotel.Meter only in metrics files; the metrics constructor takes no args
requireSortedMapIteration / disallowGoroutinesInConsensus / disallowWallClockInConsensusdeterminism in the consensus package — no map-order, goroutines, or wall-clock
disallowMultiPoolerTypeForRoutingderive leader identity from explicit state, not a topology label

govet and staticcheck — tuned, not default

Section titled “govet and staticcheck — tuned, not default”

govet runs an explicit analyzer list rather than the default set; two are notably commented out — fieldalignment (struct field reordering for memory) and shadow (variable shadowing). So vet won’t nag about field order or shadowed vars here.

staticcheck runs all minus a curated disable list — style checks ST1000/ST1003/ST1005/ST1016/ST1020-22 and the QF1001/QF1005/QF1008 quick-fixes are off.

Several presets (comments, common-false-positives, legacy, std-error-handling) are on, plus targeted relaxations:

  • gosec is globally off for a handful of checks (G115 unsafe int casts, G404 non-crypto math/rand, G306 file perms, G204 subprocess with variables) — patterns that are pervasive and intentional here.
  • errcheck is skipped under proto/; unused is skipped in the parser, the PG-protocol code, and ruleguard.
  • errorlint is relaxed in go/common/mterrors/, which does intentional direct error comparisons (see mterrors & observability).
  • unconvert is relaxed in the generated parser, which has many redundant conversions.

In the v2 schema, formatters live in their own block, separate from linters. This is the single source of truth for the import-grouping the format hooks apply:

.golangci.yml
formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- github.com/multigres

That local-prefixes value of `github.com/multigres` is what makes first-party imports group into their own block, separate from the standard library and third-party dependencies.


The Go pipeline is only half the story. A separate set of tools — driven mostly by configuration files at the repo root — covers everything else.

The directory layout of these config files is flat and predictable:

  • Directorymultigres/
    • .golangci.yml Go linters + formatters (covered above)
    • .protolint.yaml proto linting tweaks
    • .codespellrc spell-checking config
    • .editorconfig editor defaults (shell indent)
    • go.mod the tool block that supplies most linters
    • Directorymisc/
      • Directorygit/ pre-commit and commit-msg entry points
      • Directoryhooks/ the numbered hook executables
    • Directorytools/
      • run_super_linter.sh run CI’s non-Go linters locally

Minimal — the only rule present is two-space indentation for shell files. It’s consumed by editors and the shell formatters.

.editorconfig
[[shell]]
indent_style = space
indent_size = 2

Tweaks Google-style proto linting: it removes two enum-naming rules and sets a 120-character line limit.

.protolint.yaml
lint:
rules:
remove:
- ENUM_FIELD_NAMES_PREFIX
- ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH
rules_option:
max_line_length:
max_chars: 120

The protolint hook auto--fixes proto files on commit; CI’s super-linter re-checks them. See gRPC & protobuf.

Spell-checking for text, with skip globs (the generated parser, lockfiles, SVGs, patches) and an allow-list for intentional “misspellings”:

.codespellrc
[codespell]
skip = */go/common/parser/*,go.sum,**/pnpm-lock.yaml,dist,*.tsbuildinfo,*.svg,*.patch
ignore-words-list = copys,usera,anull,atleast

codespell is CI-only, run via super-linter — there is no local codespell hook.

super-linter is the umbrella that runs protolint, codespell, shfmt/shellcheck, prettier, markdownlint, and more in CI. To reproduce it locally, a helper script runs the same container against your working tree:

tools/run_super_linter.sh
docker run --platform linux/x86_64 \
$TTY_FLAGS \
--env-file ".github/super-linter.env" \
--env-file ".github/local-super-linter.env" \
-v "$PWD:/tmp/lint" \
ghcr.io/super-linter/super-linter:latest

The shared super-linter.env sets DEFAULT_BRANCH=main, an exclude filter (skipping external/, *.pb.go, go.sum, SVGs), and turns Go validation off (VALIDATE_GO=false) since golangci-lint handles that separately. The local overlay layers on RUN_LOCAL=true and autofix flags, so a local run fixes instead of merely reporting. Run it before pushing if you’ve touched YAML, Markdown, shell, or proto and want to skip a CI round-trip.


What CI enforces, and how it can disagree with you

Section titled “What CI enforces, and how it can disagree with you”

Three workflows guard a pull request:

WorkflowWhat it does
golangci-lint.ymlruns golangci-lint pinned to v2.6.1 on the whole module
lint.ymlsuper-linter for non-Go files; a check that Actions are SHA-pinned; validate-generated-files
lint-pr-title.ymlenforces a Conventional-Commits PR title

validate-generated-files runs a clean build-all then go mod tidy, and fails if git status reports any modified tracked file — i.e. if committed generated code (go/pb/, the parser, the AST helpers) is stale. The format hooks deliberately skip generated files; this job is what guards them. Regenerate and commit per Modules, Deps & Codegen.

The PR-title check requires titles to match feat|fix|docs|test|refactor|chore|build|ci|perf, e.g. fix(multipooler): handle nil pointer in health check.

Why a clean local commit can still fail CI

Section titled “Why a clean local commit can still fail CI”

This is the practical payoff of understanding the two layers — the seams between them are exactly where surprises live:

  • Version drift. The local hook requires golangci-lint >= v2.6.0; CI pins exactly v2.6.1. A check tightened between versions can pass locally and fail in CI, or vice versa.
  • Scope. The local hook lints only the packages of your changed files; CI lints the whole module, so a change that breaks a consumer of your package surfaces only in CI.
  • goconst is disabled precisely because of CI-vs-local divergence — a built-in reminder that environment differences are real here.
  • Cache lag. The CI cache key excludes .golangci.yml and the linter version, so config or version changes may not take effect until the cache expires.
  • Silent local skips. The prettier and shfmt hooks no-op if those binaries aren’t installed, but super-linter enforces them in CI.

Why is there no make lint or make fmt, and what single command wires up local formatting? Formatting and linting are enforced by git pre-commit hooks (auto-format + golangci-lint on changed packages) and by CI, not by on-demand Makefile targets. make tools symlinks the hooks into the git hooks directory; without it, nothing runs locally.
Which lint/format tools come from go tool, and which one is go installed by a hook? gofumpt, goimports, protolint, and ruleguard are registered in the go.mod tool block and run via go tool. golangci-lint is the exception: it is not a go tool dep, so its hook go installs it on demand if it’s missing from your PATH.
What does the inverted-glob pattern in the per-service depguard rules express? It applies the rule to every .go file except the service’s own service and cmd packages (the leading ! negations carve those out), then denies importing that service from everywhere else — enforcing “this service can only be imported by its own package and cmd.”
Why is os.Exit forbidden, and where is it still allowed? os.Exit terminates the process immediately, so deferred cleanup (pools, telemetry, locks) never runs; the idiomatic fix is to return an error up to main. It’s allowed only in main() and TestMain() with an inline //nolint:forbidigo.
Name three reasons a clean local commit can still fail CI. Version drift (local requires >= v2.6.0, CI pins exactly v2.6.1); scope (local lints only changed packages, CI lints the whole module); and silent local skips (prettier/shfmt no-op without the binaries, but super-linter enforces them). Cache lag and the deliberately-disabled goconst are further examples.

  1. Read the inverted globs. Open the multipooler-isolation rule in .golangci.yml and explain, in your own words, how the negated globs enforce “multipooler can only be imported by its own package and cmd.” List the exempt directories.

  2. Trace format-on-commit. Starting from the tools target in the Makefile, list the files symlinked into the git hooks directory, then the order the misc/hooks/* executables run. Which hooks auto-skip when their tool is missing?

  3. Map the ruleguard rules. Open go/tools/ruleguard/rules.go and match each custom rule to the concept it enforces (use the table above as an answer key). Which build-tags entry in .golangci.yml makes these rules run at all?

  4. Construct a version-drift failure. Compare the version the local golangci-lint hook requires against the version pinned in golangci-lint.yml. Describe a scenario where local passes but CI fails.

  5. Build an exclude-paths table. Collect the excluded paths across three layers — the format hooks (go/pb/), .codespellrc (skip = ...), and super-linter’s exclude filter. Where do they overlap, and where does each cover something the others don’t?


Continue to Modules, Deps & Codegen — the go.mod tool block in depth, and how validate-generated-files guards generated code.