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:
-
Local git pre-commit hooks auto-format your staged files (and re-stage them), then run
golangci-linton the changed packages — all before the commit lands. They’re installed once, bymake tools. -
CI gates run a pinned
golangci-lintaction, asuper-linterpass 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.
flowchart TB edit["edit .go / .proto / .yml"] --> commit["git commit"] commit --> hook["pre-commit hook"] subgraph Local["Local (installed by make tools)"] hook --> fmt["goimports · gofumpt · check-license"] fmt --> lint["golangci-lint · protolint · prettier · shfmt"] end lint -->|"auto-format hooks re-stage; lint hooks fail the commit"| lands["commit lands"] lands --> push["git push / open PR"] push --> CI["GitHub Actions"] subgraph Remote["CI"] CI --> gl["golangci-lint (pinned v2.6.1)"] CI --> sl["super-linter + validate-generated-files"] CI --> pr["Conventional-Commits PR title"] end
The hooks are installed by make tools, which symlinks them into the repo’s git hooks directory:
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.shWhat to run when
Section titled “What to run when”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 manually | CI job |
|---|---|---|---|
.go files | goimports, gofumpt, golangci-lint, check-license hooks | go tool goimports -l ./..., go tool gofumpt -l ./..., golangci-lint run ./... | golangci-lint.yml |
.proto files | protolint hook (auto -fix) | go tool protolint <file>.proto | super-linter |
Generated files (go/pb/, parser, AST) | not formatted by hooks | make build-all && go mod tidy && git status | validate-generated-files |
.sh / .yml / .md / .css / .ts | shfmt, 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.sh | super-linter |
| Commit / PR title | commit-msg hook | — | lint-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:
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 hook chain
Section titled “The pre-commit hook chain”The pre-commit script itself is intentionally tiny — it runs every executable in misc/hooks/ in lexical order and stops on the first failure:
set -eREPO_ROOT=$(git rev-parse --show-toplevel)for hook in "$REPO_ROOT"/misc/hooks/*; do if [ -x "$hook" ]; then $hook fidoneLexical 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:
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:
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_VERSIONfigolangci-lint config verify # catch invalid config keys that `run` would silently ignoregolangci-lint run $gopackages # only the packages containing changed filesTwo 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, prettier, shfmt — the rest
Section titled “protolint, prettier, shfmt — the rest”protolintrunsgo tool protolint -fixon staged.protofiles, then re-checks. Config in.protolint.yaml(below).prettierformatsyml/yaml/md/mdx/html/css/ts/tsx— but only ifprettieris installed; otherwise it silently skips.shfmtformats shell files — likewise silently skips ifshfmtisn’t installed.
Walking .golangci.yml
Section titled “Walking .golangci.yml”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:
version: "2"run: timeout: 10m build-tags: - ruleguard # required for the custom gocritic ruleguard rules to compile/runEnabled linters
Section titled “Enabled linters”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 · gocriticgosec · govet · ineffassign · mirror · misspell · modernize · nilerr · nolintlintnosprintfhostport · perfsprint · sloglint · sqlclosecheck · staticcheck · unconvertunused · whitespacemodernize 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.
forbidigo — banning os.Exit
Section titled “forbidigo — banning os.Exit”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 undergo/tools/**may import only the standard library and othergo/toolspackages. Generic helpers stay generic.parser-isolation—go/common/parser/**may depend only on itself withingo/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):
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:
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:
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.
| Rule | What it enforces |
|---|---|
disallowUnderscoreInFlags | flag names use dashes, not underscores |
requireGrpcCommonNewClient | use grpccommon.NewClient(), not grpc.NewClient(), so telemetry is attached |
disallowDirectExecCommandContext / disallowDirectProcessTermination | use the process wrapper, not raw exec.CommandContext or signals |
requireContextBackgroundJustification | context.Background() needs a justification; prefer TODO() or an explicit detach |
disallowOtelMeterOutsideMetricsFiles / disallowMetricsConstructorArgs | otel.Meter only in metrics files; the metrics constructor takes no args |
requireSortedMapIteration / disallowGoroutinesInConsensus / disallowWallClockInConsensus | determinism in the consensus package — no map-order, goroutines, or wall-clock |
disallowMultiPoolerTypeForRouting | derive 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.
Exclusions — where the rules relax
Section titled “Exclusions — where the rules relax”Several presets (comments, common-false-positives, legacy, std-error-handling) are on, plus targeted relaxations:
gosecis globally off for a handful of checks (G115unsafe int casts,G404non-cryptomath/rand,G306file perms,G204subprocess with variables) — patterns that are pervasive and intentional here.errcheckis skipped underproto/;unusedis skipped in the parser, the PG-protocol code, and ruleguard.errorlintis relaxed ingo/common/mterrors/, which does intentional direct error comparisons (see mterrors & observability).unconvertis relaxed in the generated parser, which has many redundant conversions.
Formatters block
Section titled “Formatters block”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:
formatters: enable: - gofumpt - goimports settings: goimports: local-prefixes: - github.com/multigresThat 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.
Non-Go linting
Section titled “Non-Go linting”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
toolblock 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
.editorconfig
Section titled “.editorconfig”Minimal — the only rule present is two-space indentation for shell files. It’s consumed by editors and the shell formatters.
[[shell]]indent_style = spaceindent_size = 2.protolint.yaml
Section titled “.protolint.yaml”Tweaks Google-style proto linting: it removes two enum-naming rules and sets a 120-character line limit.
lint: rules: remove: - ENUM_FIELD_NAMES_PREFIX - ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH rules_option: max_line_length: max_chars: 120The protolint hook auto--fixes proto files on commit; CI’s super-linter re-checks them. See gRPC & protobuf.
.codespellrc
Section titled “.codespellrc”Spell-checking for text, with skip globs (the generated parser, lockfiles, SVGs, patches) and an allow-list for intentional “misspellings”:
[codespell]skip = */go/common/parser/*,go.sum,**/pnpm-lock.yaml,dist,*.tsbuildinfo,*.svg,*.patchignore-words-list = copys,usera,anull,atleastcodespell is CI-only, run via super-linter — there is no local codespell hook.
Running CI’s non-Go linters locally
Section titled “Running CI’s non-Go linters locally”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:
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:latestThe 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:
| Workflow | What it does |
|---|---|
golangci-lint.yml | runs golangci-lint pinned to v2.6.1 on the whole module |
lint.yml | super-linter for non-Go files; a check that Actions are SHA-pinned; validate-generated-files |
lint-pr-title.yml | enforces 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.
goconstis 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.ymland the linter version, so config or version changes may not take effect until the cache expires. - Silent local skips. The
prettierandshfmthooks no-op if those binaries aren’t installed, but super-linter enforces them in CI.
Checkpoints
Section titled “Checkpoints”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.Exercises
Section titled “Exercises”-
Read the inverted globs. Open the
multipooler-isolationrule in.golangci.ymland 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. -
Trace format-on-commit. Starting from the
toolstarget in the Makefile, list the files symlinked into the git hooks directory, then the order themisc/hooks/*executables run. Which hooks auto-skip when their tool is missing? -
Map the ruleguard rules. Open
go/tools/ruleguard/rules.goand match each custom rule to the concept it enforces (use the table above as an answer key). Whichbuild-tagsentry in.golangci.ymlmakes these rules run at all? -
Construct a version-drift failure. Compare the version the local
golangci-linthook requires against the version pinned ingolangci-lint.yml. Describe a scenario where local passes but CI fails. -
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.