go-dev
Opinionated Go development setup with golangci-lint v2, gofumpt, gotestsum, golang-migrate, and just. Use when creating a new Go project, setting up linting, formatting, testing, or coverage, configuring a Go CI pipeline, writing a Justfile, wiring database migrations, or migrati
Install
npx skills add https://github.com/tenequm/skills/tree/main/skills/go-dev
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
git clone https://github.com/tenequm/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Go Development Stack
Opinionated, modern Go development setup. One tool per concern, zero overlap.
When to Use
- Starting a new Go project from scratch
- Adding linting, formatting, or testing infrastructure
- Setting up CI/CD for a Go service or library
- Creating a Justfile to replace a Makefile
- Adding database migration tooling
- Migrating from scattered gofmt/govet/staticcheck invocations to a unified setup
The Stack
| Tool | Version | Role | Replaces |
|---|---|---|---|
| Go | 1.27+ | Language, toolchain, go mod, go fix |
- |
| golangci-lint | v2.13+ | Meta-linter (100+ linters + formatters + fmt command) |
gofmt, govet, staticcheck, errcheck run separately |
| gofumpt | v0.12+ | Strict formatter (superset of gofmt, 19 default rules) | gofmt |
| gotestsum | v1.13+ | Test runner with readable output, watch mode, JUnit XML | Raw go test |
| just | 1.58+ | Task runner | Makefile |
| golang-migrate | v4.20+ | DB migrations (CLI + library + embed.FS) |
Manual SQL scripts |
| lefthook | v2.1+ | Git hooks (single binary, parallel) | pre-commit (Python) |
Version floors are load-bearing. golangci-lint "supports Go versions lower or equal to the Go version used to compile it" - a pin older than your Go toolchain fails outright. Go 1.27 support landed in golangci-lint v2.13.0, so v2.13 is the floor for a Go 1.27 project. Two more floors moved recently: gofumpt v0.12.0 "is based on Go 1.27's gofmt, and requires Go 1.26 or later", and lefthook's go install path now asks for Go 1.26+.
Quick Start: New Project
# 1. Create module
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
# 2. Scaffold directories
mkdir -p cmd/myapp internal migrations
# 3. Install golangci-lint as a binary, not as a module tool (see note below)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.2
# 4. Track the rest in go.mod (Go 1.24+ tool directive). Pin versions - never @latest,
# which recompiles the tool on every CI run and drifts between machines.
go get -tool mvdan.cc/gofumpt@v0.12.0
go get -tool gotest.tools/gotestsum@v1.13.0
# golang-migrate needs a build tag, so install it directly
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.20.1
# 5. Create config files (templates below)
# 6. Run: just check
Do not install golangci-lint through the tools pattern. Upstream is explicit: "Using go install/go get, "tools pattern", and tool command/directives installations aren't guaranteed to work. We recommend using binary installation." The reason that matters in a shared repo is dependency bleed - "the dependencies of a tool can modify the dependencies of another tool or your project". If you must have it in go.mod, isolate it behind its own -modfile - see the golangci-lint Reference.
go get -tool tracks; go tool runs. The tool directive records the dependency in go.mod but puts nothing on your PATH. Either invoke through the toolchain - go tool gofumpt -l ., go tool gotestsum --format testname - or go install tool once to populate $(go env GOPATH)/bin. The Justfile below calls the bare binaries, so it assumes the go install tool route (or a system install via Homebrew). Note that go tool resolves against the module in the current directory - "additional tools may be defined in the go.mod of the current module" - so in a monorepo it fails with go: no such tool "..." unless the recipe sets [working-directory(...)].
Two Go-command behaviours worth knowing before the first commit:
go mod initunder a 1.N toolchain writesgo 1.(N-1).0, not1.N- "Runninggo mod initusing a toolchain of version1.N.Xwill create ago.modfile specifying the Go versiongo 1.(N-1).0." Bump the directive deliberately if you want 1.N language features.- Pin the toolchain for reproducibility with a
toolchain go1.27.1line ingo.mod(orGOTOOLCHAIN=go1.27.1in CI). Pin the current patch, not the.0: this line is whatgovulncheckcompares stdlib advisories against, so a stale patch red-lights CI on its own - see Footguns below.
.golangci.yml
version: "2"
run:
timeout: 5m
linters:
default: standard
enable:
- bodyclose
- copyloopvar
- dupl
- durationcheck
- err113
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- goconst
- gocritic
- gosec
- intrange
- misspell
- modernize
- musttag
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- nonamedreturns
- perfsprint
- prealloc
- revive
- sqlclosecheck
- testifylint
- thelper
- unconvert
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- wrapcheck
settings:
govet:
enable:
- shadow
gocritic:
enabled-checks:
- nestingReduce
revive:
enable-all-rules: true
rules:
# enable-all-rules turns on `unhandled-error`, which flags `fmt.Println` in main.
# Under enable-all-rules a rule's `arguments` are ignored (the rule registers
# twice), so an allowlist does not work here - only `disabled` takes effect.
- name: unhandled-error
disabled: true
errcheck:
check-type-assertions: true
exclusions:
generated: strict
presets:
- comments
- std-error-handling
- common-false-positives
rules:
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
- wrapcheck
formatters:
enable:
- gofumpt
- goimports
settings:
gofumpt:
# Select rules individually. `extra-rules: true` is deprecated, and it also
# switches on `balance_calls`, which gofumpt itself demoted as controversial.
extra:
group-params: true
clothe-returns: true
balance-calls: false
exclusions:
generated: strict
paths:
- vendor/
output:
formats:
text:
path: stdout
print-linter-name: true
colors: true
sort-order:
- linter
- file
show-stats: true
Justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
binary := "myapp"
[private]
default:
@just --list --unsorted
# ── Code Quality ──────────────────────────────────────────
# Format all Go code
[group('quality')]
fmt:
golangci-lint fmt ./...
# Check formatting without modifying (CI-safe)
[group('quality')]
fmt-check:
golangci-lint fmt --diff ./...
# Run linter
[group('quality')]
lint:
golangci-lint run ./...
# Run linter with auto-fix
[group('quality')]
lint-fix:
golangci-lint run --fix ./...
# Run vulnerability check
[group('quality')]
vuln:
govulncheck ./...
# ── Testing ───────────────────────────────────────────────
# Run all tests with race detection
[group('test')]
test *args="./...":
gotestsum --format testname -- -race {{ args }}
# Run tests with coverage
[group('test')]
test-cov:
gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
# Open coverage report in browser
[group('test')]
coverage: test-cov
go tool cover -html=coverage.out
# Run integration tests
[group('test')]
test-integration:
gotestsum --format testname -- -race -tags=integration ./...
# Watch tests during development
[group('test')]
test-watch:
gotestsum --watch --watch-clear --format testname
# Run benchmarks
[group('test')]
bench:
go test -bench=. -benchmem ./...
# ── Build ─────────────────────────────────────────────────
# Build the binary
[group('build')]
build:
go build -o {{ binary }} ./cmd/{{ binary }}
# Build optimized release binary
[group('build')]
build-release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }}
# ── Dependencies ──────────────────────────────────────────
# Tidy and verify modules
[group('deps')]
tidy:
go mod tidy
go mod verify
# Run code generators
[group('deps')]
generate:
go generate ./...
# ── Database ──────────────────────────────────────────────
# Apply all pending migrations
[group('db')]
migrate-up:
migrate -path migrations -database "$DATABASE_URL" up
# Revert last migration
[group('db')]
migrate-down:
migrate -path migrations -database "$DATABASE_URL" down 1
# Create a new migration
[group('db')]
migrate-create name:
migrate create -ext sql -dir migrations -seq {{ name }}
# ── CI ────────────────────────────────────────────────────
# Full CI gate (format check + lint + test)
[group('ci')]
check: fmt-check lint test
@echo "All checks passed"
# Clean build artifacts
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
Lefthook Config
Lefthook is preferred over pre-commit for Go projects - it is a single Go binary, runs hooks in parallel, and needs no Python.
go install github.com/evilmartians/lefthook/v2@v2.1.14 # needs Go 1.26+
lefthook install
# lefthook.yml
assert_lefthook_installed: true # fail loudly instead of skipping every rule
pre-commit:
piped: true # fail fast - stop at the first failing job
commands:
fmt:
glob: "*.go"
run: golangci-lint fmt {staged_files}
stage_fixed: true
lint:
glob: "*.go"
# Never pass a bare file list to `golangci-lint run`: a list spanning two
# directories is rejected outright, and one file of a multi-file package
# reports phantom `undefined:` typecheck errors. Lint the packages instead.
run: printf '%s\n' {staged_files} | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix
stage_fixed: true
mod-tidy:
glob: "*.{go,mod,sum}"
run: go mod tidy
pre-push:
commands:
test:
run: go test -race ./...
piped: true is fail-fast, not ordering - lefthook "runs commands and scripts sequentially by default", and piped adds "Stop running commands and scripts if one of them fail." It cannot be combined with parallel: true.
jobs: (added in lefthook 1.10.0) is the newer primitive alongside the commands:/scripts: split - "Jobs provide a flexible way to define tasks, supporting both commands and scripts. Jobs can be grouped for advanced flow control." commands: is not deprecated and stays fully documented; reach for jobs: when you need grouping, nested control flow, or a mix of inline commands and scripts in one hook.
Four more worth wiring:
assert_lefthook_installed: true, above, is the antidote to the dormancy footgun below: "fail (with exit status 1) iflefthookexecutable can't be found in $PATH".lefthook validatein CI catches a malformedlefthook.ymlbefore it silently disables hooks;lefthook dumpprints the merged effective config when a hook does not behave as written.- A gitignored
lefthook-local.ymllets a developer add or skip jobs without imposing it on teammates - "This is useful when you want to use lefthook locally without imposing it on your teammates." - In a monorepo, give each job a
root:pointing at its module directory; without itgo mod tidyandgo toolrun against the repo root and fail.
Beta, but worth knowing: ai: declares LLM agent hooks in the same file - "During lefthook install, lefthook generates the provider-specific settings file so that the agent calls lefthook run <hook> when the event fires", for claude, codex, cursor, and copilot. See the Lefthook Reference for the wider config surface.
Project Structure
myapp/
cmd/
myapp/
main.go # Wire deps, call Run(), nothing else
internal/
user/ # Domain logic, one package per domain
user.go
user_test.go
repository.go
transport/ # HTTP/gRPC handlers
storage/ # Database layer
migrations/
000001_create_users.up.sql
000001_create_users.down.sql
testdata/ # Test fixtures (ignored by go toolchain)
.golangci.yml
lefthook.yml
Justfile
go.mod
go.sum
Dockerfile
Guidelines:
cmd/- one directory per binary, keepmain.gothin (~50 lines max)internal/- all business logic goes here (compiler-enforced, cannot be imported externally)pkg/- only add when another repo actually imports it today, not "maybe someday"testdata/- test fixtures, golden files, fuzz corpusmigrations/- SQL migration files (timestamp or sequential versioned)
Daily Workflow
just fmt # Format code
just lint # Run linter
just test # Run tests with race detection
just check # Full CI gate (fmt-check + lint + test)
just test-watch # Watch mode during development
just generate # Run go generate
just tidy # go mod tidy + verify
go fix is the toolchain-native complement to the modernize linter: Go 1.26 rebuilt it as a codebase modernizer - "The venerable go fix command has been completely revamped and is now the home of Go's modernizers. It provides a dependable, push-button way to update Go code bases to the latest idioms and core library APIs." Run go fix ./... after a toolchain bump, before the linter has to complain. Go 1.27 added four more modernizers - "The go fix command contains several new modernizers (atomictypes, embedlit, slicesbackward, and unsafefuncs)" - and removed fmtappendf, so a 1.27 bump is a good moment to run it.
Three other Go 1.27 changes touch this stack directly:
- Generic methods. "Go 1.27 now supports generic methods: a method declaration may declare its own type parameters."
encoding/json/v2. "The encoding/json package is now backed by the v2 implementation" - behaviour-compatible by default, but worth knowing before you debug a marshalling difference.go test -jsongained anOutputTypefield, annotating"Action":"output"lines. This is the stream gotestsum consumes, so it lands in your test tooling whether or not you use it directly.
Footguns
Seven failure modes that cost real debugging time, none of which produce an obvious error message.
Config placement is load-bearing. .golangci.yml must sit at the repo root: golangci-lint searches the working dir and its parents, and editor Go plugins auto-detect only a root .golangci.*, so filing it under .github/ costs in-IDE linting even if you pass --config. lefthook auto-discovers only the repo root or .config/ - move lefthook.yml anywhere else and commits silently stop running hooks, because git invokes the hook directly and no task-runner recipe can intercept that.
lefthook is dormant until installed. The binary being absent from PATH, or lefthook install never having run, both present as "hooks just don't fire" with no warning. Set assert_lefthook_installed: true so this fails loudly, pin lefthook as a repo tool, and make lefthook install part of onboarding.
A stale lint cache invents issues. golangci-lint can report failures in files that no longer exist on disk - typically after a branch switch or a deleted worktree. The costlier variant is nolintlint reporting a load-bearing //nolint directive as unused, which tempts you to delete a real suppression. Prove which side is lying with GL_DEBUG=nolint_filter before touching the code, and run golangci-lint cache clean if issue counts look impossible. When several worktrees share a checkout, give each its own cache with GOLANGCI_LINT_CACHE=<worktree>/.golangci-cache - and note the cache does not reliably invalidate on config, tool, or dependency changes, so fold those into the cache key if a phantom keeps returning.
Concurrent golangci-lint runs fail rather than queue. The lock is a single file in the system temp dir, not per-GOLANGCI_LINT_CACHE, so per-worktree cache isolation does not prevent it. A second run waits five seconds, then exits with parallel golangci-lint is running. This bites hardest in a just recipe with [parallel] that runs fmt and run together, on green code. Set run.allow-serial-runners: true to wait indefinitely instead of failing, or run.allow-parallel-runners: true to drop the lock entirely.
Don't run two formatters against one gate. Standalone gofumpt -w and golangci-lint fmt do not always agree on the same file, so a repo that fixes with one and gates with the other fails CI on code it just formatted. This is currently live rather than theoretical: golangci-lint v2.13.2 vendors gofumpt v0.11.0, while a standalone install is v0.12.0, and v0.12.0 changed how imports carrying comments and blank lines are laid out. Pick one as both fixer and gate - the Justfile and the hook above both use golangci-lint fmt.
A pinned linter older than your Go toolchain fails outright. This is the same trap as the version floor above, and it usually surfaces first as a config-schema rejection: a config authored against a newer golangci-lint hits additional properties ... not allowed under the pinned CI version. Bump the CI pin and the local install together.
govulncheck fails on stdlib advisories, not just your code. Advisories are matched against the toolchain line in go.mod, so a lagging toolchain red-lights CI on commits that touch zero Go code - and a failed test-and-lint job typically skips the release job downstream. When govulncheck reports vulnerabilities "in the Go standard library" all marked fixed in a patch you don't have, the fix is bumping the toolchain, not editing code.
CI/CD Pipeline (GitHub Actions)
name: Go CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: stable
- uses: golangci/golangci-lint-action@v9
with:
version: v2.13
- name: Verify lint config against the pinned binary
run: golangci-lint config verify
test:
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
go-version: [stable, oldstable]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go-version }}
- run: go install gotest.tools/gotestsum@v1.13.0
- name: Test
run: gotestsum --format github-actions --junitfile unit-tests.xml -- -race -coverprofile=coverage.out -covermode=atomic ./...
- uses: actions/upload-artifact@v7
if: always()
with:
name: test-results-${{ matrix.go-version }}
path: unit-tests.xml
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: stable
- run: go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
- run: govulncheck ./...
Two setup-go behaviours decide whether this workflow is fast or pathologically slow:
- It hashes a repo-root
go.mod. Caching is on by default, but a module in a subdirectory never matches, so every run logs a restore failure and cold-compiles the whole dependency tree. Pointcache-dependency-pathat the real file. - The cache is saved in a post step declared
post-if: success(). A job that fails saves nothing, so a cold run that times out stays cold forever and raising the timeout never breaks the loop. Split lint and test into separate jobs so one slow gate cannot starve the other's cache.
golangci-lint config verify earns its place as an explicit step: a config authored against a newer binary is accepted locally and rejected by the pinned CI version, and without this step that surfaces as a confusing lint failure much later in the job.
Existing Project Migration
# 1. Install tools (golangci-lint as a binary - see Quick Start)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.2
go install mvdan.cc/gofumpt@v0.12.0
go install gotest.tools/gotestsum@v1.13.0
# 2. Migrate existing golangci-lint v1 config
golangci-lint migrate
# 3. Format codebase
gofumpt -w .
# 4. Run linter (fix what you can, nolint the rest)
golangci-lint run --fix ./...
# 5. Replace go test with gotestsum in scripts/CI
# Before: go test -v ./...
# After: gotestsum --format testname -- -race ./...
# 6. Copy Justfile and lefthook.yml templates above
# 7. Run: just check
For incremental adoption on large codebases, use only-new-issues: true in the GitHub Action to only lint changed code. Outside the Action, --new-from-merge-base=main and --new-from-rev=<rev> do the same locally - see the golangci-lint Reference for the full set.
Expect new findings after a toolchain bump: since Go 1.27, "go test now invokes the stdversion vet check by default. This reports the use of standard library symbols that are too new for the Go version in force in the referring file". Adjust the go directive or the call site rather than suppressing it.
Adjacent Tools
Not part of the core stack, but the gaps most projects fill next:
| Need | Tool | Why |
|---|---|---|
| Structured logging | log/slog (stdlib) |
The default since Go 1.21; the sloglint linter enforces a consistent call style |
| Hot reload for a running service | air or wgo | just test-watch covers tests; neither go run nor gotestsum restarts a server on save |
| Release binaries + changelog | GoReleaser | Cross-compile, checksum, sign, and publish from one config |
| Type-safe SQL from schema | sqlc | Generates Go from the same SQL your migrations define, so storage/ stays hand-written-free |
Reference Docs
- golangci-lint Reference - v2 config, linter catalog, recommended sets, nolint syntax
- gofumpt Reference - formatting rules, editor integration, golangci-lint integration
- gotestsum Reference - output formats, watch mode, JUnit XML, CI recipes
- Go Testing Reference - table-driven tests, mocking, benchmarks, coverage, fuzz testing
- golang-migrate Reference - CLI, library, embed.FS, transactions, pitfalls
- Justfile Reference - Go-specific recipes, task groups, lefthook integration
- Lefthook Reference - job filtering, monorepo roots, remote configs, CLI, env vars
Resources
Files (skills)
-
references
-
go-migrate-reference.md 11.7 KB
# golang-migrate Reference Latest: **v4.20.1** (2026-09-09). Built with Go 1.25/1.26. MIT license, 18K+ stars. **Pin v4.20.1, not v4.20.0.** A release-workflow bug meant v4.20.0 exists as a git tag but never reached Docker or the package registries - "Due to a bug in the release workflow, GoReleaser failed and `v4.20.0` was not published to Docker or other package registries." v4.20.1 is that release redistributed, and carries no other changes. v4.20.0 is worth upgrading for regardless of the pin mechanics: - **S3 sources silently truncated at 1000 migrations** - "fix(source/aws_s3): paginate ListObjects to load >1000 migrations". - **Quadratic startup cost removed** - "perf(source): build migrations index lazily to avoid quadratic startup". - **Security:** the `docker/docker` dependency was swapped for `moby/moby` modules to clear a scanner finding. ## Installation ### CLI ```bash # Homebrew (macOS) brew install golang-migrate # Scoop (Windows) scoop install migrate # Pre-built binary curl -L https://github.com/golang-migrate/migrate/releases/download/v4.20.1/migrate.linux-amd64.tar.gz | tar xvz # With Go (specify database driver via build tags) go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.20.1 # Docker docker run -v $(pwd)/migrations:/migrations --network host migrate/migrate \ -path=/migrations/ -database "postgres://localhost:5432/db" up ``` Multiple drivers: `-tags 'postgres mysql sqlite3'` ### Library ```bash go get github.com/golang-migrate/migrate/v4 ``` ## Migration File Naming Format: `{version}_{title}.{direction}.sql` ### Sequential (recommended for smaller teams) ```bash migrate create -ext sql -dir migrations -seq create_users_table ``` Produces: ``` migrations/ 000001_create_users_table.up.sql 000001_create_users_table.down.sql ``` Control zero-padding with `-digits N` (default: 6). Two more `create` flags: `-format` takes "a Go time format string" for the version prefix, and `-tz` sets the timezone used to generate it. ### Timestamp (better for larger teams) ```bash migrate create -ext sql -dir migrations create_users_table ``` Produces: ``` migrations/ 1712345678_create_users_table.up.sql 1712345678_create_users_table.down.sql ``` Eliminates version conflicts when multiple developers create migrations simultaneously. ## CLI Commands ```bash # Apply all pending migrations migrate -path migrations -database "$DATABASE_URL" up # Apply next N migrations migrate -path migrations -database "$DATABASE_URL" up 2 # Revert last N migrations migrate -path migrations -database "$DATABASE_URL" down 1 # Revert ALL (interactive confirmation) migrate -path migrations -database "$DATABASE_URL" down # Revert all without confirmation migrate -path migrations -database "$DATABASE_URL" down -all # Check current version migrate -path migrations -database "$DATABASE_URL" version # Migrate to specific version (up or down) migrate -path migrations -database "$DATABASE_URL" goto 3 # Fix dirty database state (set version without running migration) migrate -path migrations -database "$DATABASE_URL" force 2 # Drop everything (dangerous) migrate -path migrations -database "$DATABASE_URL" drop -f # Create new migration files migrate create -ext sql -dir migrations -seq add_email_column ``` **CLI options:** - `-source` - migration source (driver://url) - `-path` - shorthand for `-source=file://path` - `-database` - database connection URL - `-prefetch N` - migrations to load ahead (default 10) - `-lock-timeout N` - seconds to acquire lock (default 15) - `-verbose` - verbose logging Handles `SIGINT` gracefully, stopping at a safe point. Programmatically the same guarantee is exposed as a channel - "To help prevent database corruptions, it supports graceful stops via `GracefulStop chan bool`" - and the library takes your own logger via its `Logger` interface ("Bring your own logger.") rather than writing to stdout. ## Up/Down Migration Patterns **Up migration** (`000001_create_users.up.sql`): ```sql CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_users_email ON users (email); ``` **Down migration** (`000001_create_users.down.sql`): ```sql DROP TABLE IF EXISTS users; ``` **Multi-statement with transaction** (`000002_add_status.up.sql`): ```sql BEGIN; CREATE TYPE user_status AS ENUM ('active', 'inactive', 'banned'); ALTER TABLE users ADD COLUMN status user_status NOT NULL DEFAULT 'active'; COMMIT; ``` **Down** (`000002_add_status.down.sql`): ```sql BEGIN; ALTER TABLE users DROP COLUMN status; DROP TYPE user_status; COMMIT; ``` ## Library Usage ### Basic (URL-based) ```go import ( "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" ) m, err := migrate.New( "file://migrations", "postgres://user:pass@localhost:5432/mydb?sslmode=disable") if err != nil { log.Fatal(err) } if err := m.Up(); err != nil && err != migrate.ErrNoChange { log.Fatal(err) } ``` ### With Existing Connection ```go import ( "database/sql" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" ) db, _ := sql.Open("postgres", connStr) driver, _ := postgres.WithInstance(db, &postgres.Config{}) m, _ := migrate.NewWithDatabaseInstance("file://migrations", "postgres", driver) m.Up() // Apply ALL pending migrations - not just the next one m.Steps(2) // Apply exactly 2 up m.Steps(-1) // Revert 1 m.Version() // Get current version + dirty flag m.Force(3) // Set version without running m.Close() // Close connections ``` Always check for `migrate.ErrNoChange` when calling `Up()` - it means no new migrations exist and is not a real error. **`Up()` and `Steps(1)` are not interchangeable.** `Up()` applies every pending migration in one call; `Steps(1)` applies exactly one. Automation wired to `Up()` on merge will apply an entire backlog of DDL the first time it runs, which surprises teams who assumed each deploy advanced one version. Decide deliberately which semantics a given entry point has, and name it accordingly. ## Embedding Migrations with embed.FS Produce self-contained binaries by embedding migrations at compile time: ```go package main import ( "embed" "log" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" ) //go:embed migrations/*.sql var migrationsFS embed.FS func runMigrations(databaseURL string) error { d, err := iofs.New(migrationsFS, "migrations") if err != nil { return err } m, err := migrate.NewWithSourceInstance("iofs", d, databaseURL) if err != nil { return err } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return err } return nil } ``` The `//go:embed` directive path is relative to the Go source file containing it. ## PostgreSQL-Specific URL format: `postgres://user:password@host:port/dbname?query` | Parameter | Description | |-----------|-------------| | `x-migrations-table` | Custom migrations table name (default: `schema_migrations`) | | `x-statement-timeout` | Abort statements exceeding N ms | | `x-multi-statement` | Enable multi-statement execution (default: false) | | `x-multi-statement-max-size` | Max statement size in bytes (default: 10MB) | | `search_path` | Schema search path | | `x-migrations-table-quoted` | Disable quoting of the migrations table name - "By default, migrate quotes the migration table for SQL injection safety reasons. This option disable quoting" | | `sslmode` | disable, require, verify-ca, verify-full | Uses `pg_advisory_lock` for safe concurrent migrations. ## Transaction Handling golang-migrate does **NOT** wrap a migration in a transaction at the library level, so the portable advice is to write explicit `BEGIN`/`COMMIT`. One database-specific exception is worth knowing: "In PostgreSQL running multiple SQL statements in one `Exec` executes them inside a transaction." A multi-statement Postgres migration is therefore already atomic in practice - but writing `BEGIN`/`COMMIT` anyway costs nothing, keeps the intent explicit, and stays correct on other engines: ```sql BEGIN; ALTER TABLE users ADD COLUMN phone VARCHAR(20); CREATE INDEX idx_users_phone ON users (phone); COMMIT; ``` **Exception:** `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. Put it in its own migration file without `BEGIN`/`COMMIT`: ```sql -- 000003_add_concurrent_index.up.sql CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_name ON users (name); ``` ## Dirty Database State When a migration fails mid-execution, the database is marked "dirty" and no further migrations run. To recover: 1. Check current state: `migrate version` (shows version + dirty flag) 2. Fix the issue manually in the database 3. Force the correct version: `migrate force <version>` If the failed migration partially applied, you may need to manually undo the partial changes before forcing. ## Supported Databases PostgreSQL, PGX v4/v5, MySQL/MariaDB, SQLite, MS SQL Server, MongoDB, CockroachDB, YugabyteDB, ClickHouse, Cassandra/ScyllaDB, Neo4j, Cloud Spanner, Redshift, and more. ## Migration Sources Filesystem, `embed.FS` (iofs), GitHub, GitLab, Bitbucket, AWS S3, Google Cloud Storage. ## CI/CD Patterns ### Test Migrations (up-down-up) in CI ```yaml services: postgres: image: postgres:17 env: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: testdb ports: ["5432:5432"] steps: - name: Run migrations up run: migrate -path migrations -database "postgresql://test:test@localhost:5432/testdb?sslmode=disable" up - name: Run migrations down run: migrate -path migrations -database "postgresql://test:test@localhost:5432/testdb?sslmode=disable" down -all - name: Run migrations up again run: migrate -path migrations -database "postgresql://test:test@localhost:5432/testdb?sslmode=disable" up ``` The up-down-up pattern validates both directions work correctly. ## Common Pitfalls 1. **Dirty state after failure** - most common issue. Must `force` correct version after manual fix 2. **Version conflicts in teams** - use timestamp versioning for larger teams 3. **Missing down migrations** - always write both directions, even if down is a no-op (add a SQL comment) 4. **Non-transactional DDL** - `CREATE INDEX CONCURRENTLY` needs its own migration without `BEGIN`/`COMMIT` 5. **URL encoding** - special characters in passwords must be percent-encoded 6. **Empty files** - 0-byte migration files cause issues. Add a SQL comment if intentionally empty 7. **Schema + role name clash** in PostgreSQL - `search_path` causes migrations table duplication. Fix: set `search_path=public` in URL 8. **Never edit applied migrations** - treat merged migrations as immutable. Create new ones for corrections 9. **Not every migration is reversible** - upstream's `MIGRATIONS.md` has a "Reversibility of Migrations" section; a destructive `up` (dropping a column, collapsing rows) cannot be undone by a `down` that only restores schema. Say so in a comment rather than shipping a `down` that silently loses data ## Best Practices - Keep migrations small and focused - one logical change per migration - Always test up-down-up before merging - Use transactions for multi-statement migrations (when database supports it) - Embed migrations for production binaries (`embed.FS` + `iofs`) - Run migrations at app startup or as a separate step in the deploy pipeline - Use `migrate.ErrNoChange` check in programmatic usage - Pin `migrate` CLI version in CI for reproducibility -
go-testing-reference.md 18.1 KB
# Go Testing Reference Covers Go testing best practices, patterns, and tooling as of Go 1.27 (August 2026). ## Table-Driven Tests The idiomatic Go testing pattern. Use named struct slices with `t.Run()` for subtests: ```go func TestUserValidation(t *testing.T) { tests := []struct { name string user User wantErr error }{ { name: "valid user", user: User{Email: "[email protected]", Age: 25}, wantErr: nil, }, { name: "missing email", user: User{Age: 25}, wantErr: ErrInvalidEmail, }, { name: "negative age", user: User{Email: "[email protected]", Age: -1}, wantErr: ErrInvalidAge, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.user.Validate() if !errors.Is(err, tt.wantErr) { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } ``` **Note:** Since Go 1.22, the loop variable capture bug is fixed. `tt := tt` inside the loop is no longer needed, even with `t.Parallel()`. ## Parallel Tests ```go func TestParallel(t *testing.T) { tests := []struct { name string input int want int }{ {"double 1", 1, 2}, {"double 5", 5, 10}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Safe without tt := tt in Go 1.22+ got := Double(tt.input) if got != tt.want { t.Errorf("got %d, want %d", got, tt.want) } }) } } ``` Default parallelism = `GOMAXPROCS`. Override with `go test -parallel N`. ## Testing Helpers (Go 1.14-1.27) ### t.Helper() Mark functions as test helpers so failures report the caller's line: ```go func assertNoError(t testing.TB, err error) { t.Helper() if err != nil { t.Fatalf("unexpected error: %v", err) } } ``` Use `testing.TB` as the parameter type so helpers work in both tests and benchmarks. ### t.Cleanup(func()) - Go 1.14 Register cleanup that runs after the test and all subtests complete. LIFO order: ```go func newTestDB(t *testing.T) *DB { t.Helper() db := openDB() t.Cleanup(func() { db.Close() }) return db } ``` ### t.TempDir() - Go 1.15 Auto-cleaned temporary directory: ```go func TestWriteFile(t *testing.T) { dir := t.TempDir() // Removed automatically after test path := filepath.Join(dir, "output.txt") err := os.WriteFile(path, []byte("hello"), 0o644) require.NoError(t, err) } ``` ### t.Setenv(key, value) - Go 1.17 Set env var for test duration, restored on cleanup: ```go func TestConfig(t *testing.T) { t.Setenv("DATABASE_URL", "postgres://test@localhost/testdb") cfg := LoadConfig() assert.Equal(t, "postgres://test@localhost/testdb", cfg.DatabaseURL) } ``` Cannot be used with `t.Parallel()` (panics). ### t.Chdir(dir) - Go 1.24 Change the working directory for the duration of a test, restored on cleanup: ```go func TestLoadFromCWD(t *testing.T) { t.Chdir("testdata/project") cfg, err := LoadConfig() require.NoError(t, err) } ``` Like `t.Setenv`, it cannot be combined with `t.Parallel()`. ### t.Context() - Go 1.24 Returns a context cancelled when the test finishes - "The new `T.Context` and `B.Context` methods return a context that's canceled after the test completes and before test cleanup functions run.": ```go func TestWithContext(t *testing.T) { ctx := t.Context() result, err := service.Fetch(ctx, "key") require.NoError(t, err) } ``` ### t.ArtifactDir() - Go 1.26 Directory for test artifacts that persists after the test. Set the location with `-outputdir`; emit a manifest with `-artifacts`: ```go func TestRender(t *testing.T) { dir := t.ArtifactDir() path := filepath.Join(dir, "output.html") os.WriteFile(path, rendered, 0o644) } ``` Available as `T.ArtifactDir`, `B.ArtifactDir`, and `F.ArtifactDir`. ### t.Attr(key, value) and t.Output() - Go 1.25 `t.Attr` attaches a structured key/value attribute to a test, surfaced in `go test -json` output; `t.Output` returns an `io.Writer` whose writes are interleaved into that same stream rather than captured as raw log lines. gotestsum v1.13.0 added support for consuming these attributes. ```go func TestPipeline(t *testing.T) { t.Attr("dataset", "fixtures/large.json") fmt.Fprintln(t.Output(), "stage 1 complete") } ``` ## Testify Latest: **v1.12.1** (August 2026). The most popular assertion library. Four packages: There will be no v2 - "Testify is being maintained at v1, no breaking changes will be accepted in this repo." ### assert (soft assertions - test continues) ```go import "github.com/stretchr/testify/assert" func TestUser(t *testing.T) { user := GetUser("123") assert.Equal(t, "alice", user.Name) assert.NotEmpty(t, user.ID) assert.NoError(t, user.Validate()) assert.Contains(t, user.Email, "@") assert.Len(t, user.Roles, 2) assert.True(t, user.Active) assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Minute) } ``` ### require (hard assertions - test stops on failure) ```go import "github.com/stretchr/testify/require" func TestFetch(t *testing.T) { result, err := Fetch("key") require.NoError(t, err) // Stops here if error require.NotNil(t, result) // Only runs if no error assert.Equal(t, "value", result.Data) } ``` **Rule of thumb:** Use `require` for preconditions (error checks, nil checks), `assert` for the actual assertions. ### suite (setup/teardown lifecycle) ```go import "github.com/stretchr/testify/suite" type UserSuite struct { suite.Suite db *sql.DB } func (s *UserSuite) SetupSuite() { s.db = connectTestDB() } func (s *UserSuite) TearDownSuite() { s.db.Close() } func (s *UserSuite) SetupTest() { truncateAll(s.db) } func (s *UserSuite) TestCreate() { err := CreateUser(s.db, "alice") s.NoError(err) } func TestUserSuite(t *testing.T) { suite.Run(t, new(UserSuite)) } ``` ### mock (expectations) ```go import "github.com/stretchr/testify/mock" type MockRepo struct { mock.Mock } func (m *MockRepo) Get(id string) (*User, error) { args := m.Called(id) return args.Get(0).(*User), args.Error(1) } func TestService(t *testing.T) { repo := new(MockRepo) repo.On("Get", "123").Return(&User{Name: "alice"}, nil) svc := NewService(repo) user, err := svc.FindUser("123") require.NoError(t, err) assert.Equal(t, "alice", user.Name) repo.AssertExpectations(t) } ``` ## Mock Libraries | Library | Approach | Best For | |---------|----------|----------| | **go.uber.org/mock** (v0.6.0) | Code gen via `mockgen` | Precise expectations, call ordering | | **vektra/mockery** (v3.8.0) | Batch code gen, templates | Large codebases (5-30x faster than sequential mockgen) | | **matryer/moq** | Function-field based mocks | Lightweight, simple mocks | | **testify/mock** | Runtime (no codegen) | Quick mocking without generators | | **Hand-written** | Interface implementation | Full control, no dependencies | ### gomock (go.uber.org/mock) ```bash go install go.uber.org/mock/mockgen@v0.6.0 ``` mockgen has two supported modes: **source mode** (`-source=repository.go`, shown below) and **package mode** (`-destination=... <import path> <interfaces>`). Package mode replaced reflect mode - "Deprecated reflect mode and replaced it with the new package mode." Do not write new `-reflect`-style invocations. ```go //go:generate mockgen -source=repository.go -destination=mock_repository.go -package=user func TestWithGomock(t *testing.T) { ctrl := gomock.NewController(t) repo := NewMockRepository(ctrl) repo.EXPECT().Get("123").Return(&User{Name: "alice"}, nil) svc := NewService(repo) user, _ := svc.FindUser("123") assert.Equal(t, "alice", user.Name) } ``` ### mockery v3 Config-driven batch processing: ```yaml # .mockery.yml (or .mockery.yaml - both are discovered) packages: github.com/yourorg/myapp/internal/user: interfaces: Repository: Service: ``` ```bash mockery # Generates all mocks in one pass ``` ## Benchmarks ### testing.B.Loop (Go 1.24+ - preferred) ```go func BenchmarkSort(b *testing.B) { data := generateData() // Setup excluded automatically for b.Loop() { sort.Ints(data) } // No b.ResetTimer() needed - setup/cleanup excluded automatically } ``` Benefits of `b.Loop()`: - Automatically excludes setup/cleanup from timing - Prevents dead-code elimination - Benchmark function called only once (faster) Go 1.26 removed the last reason to stay on `b.N` - "The `B.Loop` method no longer prevents inlining in the loop body, which could lead to unanticipated allocation and slower benchmarks. With this fix, we expect that all benchmarks can be converted from the old `B.N` style to the new `B.Loop` style with no ill effects." ### Old pattern (still works) ```go func BenchmarkSortOld(b *testing.B) { data := generateData() b.ResetTimer() for i := 0; i < b.N; i++ { sort.Ints(data) } } ``` ### Sub-benchmarks ```go func BenchmarkCache(b *testing.B) { b.Run("Set", func(b *testing.B) { for b.Loop() { cache.Set("k", "v") } }) b.Run("Get", func(b *testing.B) { for b.Loop() { cache.Get("k") } }) } ``` ### Running and Comparing ```bash go test -bench=. -benchmem ./... go test -bench=. -benchmem -count=5 ./... > old.txt # make changes go test -bench=. -benchmem -count=5 ./... > new.txt benchstat old.txt new.txt ``` Always use `-benchmem` - allocations per op often matter more than raw speed. ## Build Tags for Test Separation Use `//go:build` (not the old `// +build`): ```go //go:build integration package user_test func TestUserRepository_Integration(t *testing.T) { db := connectRealDB(t) // ... } ``` Run: `go test -tags=integration ./...` ### Alternative: testing.Short() ```go func TestSlow(t *testing.T) { if testing.Short() { t.Skip("skipping in short mode") } // expensive test } ``` Run unit tests only: `go test -short ./...` ### Alternative: Custom Flags ```go var integration = flag.Bool("integration", false, "run integration tests") func TestMain(m *testing.M) { flag.Parse() os.Exit(m.Run()) } func TestDB(t *testing.T) { if !*integration { t.Skip("pass -integration to run") } } ``` Run: `go test -integration ./...` ## Coverage ```bash go test -cover ./... # Summary go test -coverprofile=coverage.out ./... # Generate profile go tool cover -html=coverage.out # HTML report go tool cover -func=coverage.out # Function-level summary go test -coverpkg=./... ./... # Cross-package coverage ``` **Coverage modes:** - `-covermode=set` - did each statement run? (boolean) - `-covermode=count` - how many times? - `-covermode=atomic` - thread-safe count (use with `-race`) **Integration test coverage** (Go 1.20+): ```bash go build -cover -o myapp . GOCOVERDIR=./coverage_data ./myapp go tool covdata textfmt -i=./coverage_data -o coverage.out ``` ## Race Detector ```bash go test -race ./... ``` - Zero false positives - if it reports a race, it is real - ~2-10x slower, ~5-10x more memory - Only detects races on actually executed paths - **Always use `-race` in CI** - the single most important testing flag - Combine with `-count=N` for better detection ## Fuzz Testing ```go func FuzzParse(f *testing.F) { // Seed corpus f.Add("valid input") f.Add("") f.Add("edge case") f.Fuzz(func(t *testing.T, input string) { result, err := Parse(input) if err != nil { return // Invalid input is fine } // Property: round-trip should preserve data encoded := result.String() result2, err := Parse(encoded) if err != nil { t.Errorf("round-trip failed: %v", err) } if !reflect.DeepEqual(result, result2) { t.Errorf("round-trip mismatch") } }) } ``` Run: `go test -fuzz=FuzzParse` Seed corpus stored in `testdata/fuzz/<FuzzTestName>/` - commit to version control. Best for: parsers, encoders/decoders, protocol implementations, user input handling. ## Golden File Testing Compare output against a pre-approved reference file: ```go var update = flag.Bool("update", false, "update golden files") func TestRender(t *testing.T) { got := RenderTemplate(data) golden := filepath.Join("testdata", t.Name()+".golden") if *update { os.WriteFile(golden, []byte(got), 0o644) return } want, err := os.ReadFile(golden) require.NoError(t, err) if diff := cmp.Diff(string(want), got); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) } } ``` Update golden files: `go test -update ./...` **Keep goldens host-independent.** A golden whose content depends on `runtime.GOARCH`, `runtime.GOOS`, path separators, or the host's locale regenerates correctly on your machine and fails on the CI runner - the classic shape is a fixture written on arm64 macOS and asserted on amd64 Linux. Pin every host-derived input explicitly in the test rather than letting it default, or split the golden per platform. Libraries: `sebdah/goldie/v2`, `gotest.tools/v3/golden` ## testdata/ Convention Go's toolchain ignores `testdata/` directories: ``` internal/user/ user.go user_test.go testdata/ valid_user.json invalid_user.json fuzz/FuzzParse/ # Fuzz corpus ``` Access fixtures with relative paths - `go test` sets CWD to the package directory: ```go data, err := os.ReadFile("testdata/valid_user.json") ``` ## Example Tests Functions named `ExampleXxx()` serve as both tests and documentation: ```go func ExampleReverse() { fmt.Println(Reverse("hello")) // Output: olleh } ``` Appear in `go doc` output and run during `go test`. ## HTTP Testing ```go func TestHandler(t *testing.T) { handler := NewHandler(mockService) req := httptest.NewRequest("GET", "/users/123", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "alice") } func TestClient(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(User{Name: "alice"}) })) t.Cleanup(srv.Close) client := NewClient(srv.URL) user, err := client.GetUser("123") require.NoError(t, err) assert.Equal(t, "alice", user.Name) } ``` ## testcontainers-go Latest: **v0.44.0**. Spin up ephemeral infrastructure for integration tests. Use the module-specific `Run` constructor, not `GenericContainer` - "`GenericContainer` is the old way to create a container, and we recommend using `Run` instead, as it could be deprecated in the future." Note also that v0.43.0 changed `wait.ForSQL`: "Users of `wait.ForSQL` need to follow the new API contract, using Moby's `network.Port` instead of `string`". ```go func TestPostgres(t *testing.T) { ctx := t.Context() container, err := postgres.Run(ctx, "postgres:17", postgres.WithDatabase("testdb"), postgres.WithUsername("test"), postgres.WithPassword("test"), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready"), ), ) require.NoError(t, err) t.Cleanup(func() { container.Terminate(ctx) }) connStr, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err) db, err := sql.Open("postgres", connStr) require.NoError(t, err) // Run tests against real database } ``` ## synctest (Go 1.25+) Deterministic testing of concurrent code. The stable API in Go 1.25+ is `synctest.Test(t, fn)`; the experimental `synctest.Run(fn)` from Go 1.24 (under `GOEXPERIMENT=synctest`) was renamed and now takes a `*testing.T`: ```go import "testing/synctest" func TestConcurrent(t *testing.T) { synctest.Test(t, func(t *testing.T) { ch := make(chan int) go func() { ch <- 42 }() synctest.Wait() // Waits for all goroutines to block val := <-ch assert.Equal(t, 42, val) }) } ``` Go 1.27 adds two conveniences: `synctest.Sleep`, which "combines `time.Sleep` and `synctest.Wait`", and `httptest.NewTestServer`, which "creates a `Server` configured to use an in-memory fake network suitable for use with the `testing/synctest` package" - the missing piece for testing HTTP clients under a fake clock. ## Quick Reference: Test Flags ```bash go test ./... # Run all tests go test -v ./... # Verbose go test -race ./... # Race detection go test -short ./... # Skip slow tests go test -run TestFoo ./... # Run matching tests go test -run TestFoo/subcase ./... # Run specific subtest go test -count=1 ./... # Disable test cache go test -tags=integration ./... # Build tag go test -parallel 4 ./... # Max parallel tests go test -timeout 10m ./... # Test timeout go test -bench=. -benchmem ./... # Benchmarks go test -fuzz=FuzzParse ./... # Fuzzing go test -coverprofile=c.out ./... # Coverage go test -covermode=atomic ./... # Atomic coverage go test -coverpkg=./... ./... # Cross-package coverage go test -artifacts ./... # Emit an artifact manifest (Go 1.26+) go test -outputdir=./out ./... # Where t.ArtifactDir() writes ``` **Go 1.27 annotates the JSON stream.** `go test -json` "now annotates `"Action":"output"` lines with an optional new field `"OutputType"`", distinguishing framework output from a test's own writes. Anything parsing `go test -json` - gotestsum, CI report generators, custom tooling - sees this field appear after a toolchain bump; consumers that validate the schema strictly may need updating. **A cached green run proves nothing.** `go test` caches results for unchanged packages and replays them, so a passing run may not have executed a single test. When a result matters - before a release, after a dependency bump, when confirming a fix - pass `-count=1` to force real execution. This is why the CI recipes in this skill use it. -
gofumpt-reference.md 10.2 KB
# gofumpt Reference Latest: **v0.12.0** (2026-09-07). Based on Go 1.27's gofmt - "This release is based on Go 1.27's gofmt, and requires Go 1.26 or later." gofumpt is a **strict superset of gofmt** - any code formatted by gofumpt produces zero changes when processed by gofmt. It adds 19 opinionated formatting rules on top, plus 3 opt-in extra rules. **Upgrading to v0.12.0 reformats imports.** Four fixes change output on real code, so expect a one-time diff: a std import carrying a comment is "no longer moved into the top import group, as the comment stayed behind and ended up detached at the bottom of the group"; moving a std import up "no longer leaves an empty line where it used to be"; a copyright header or package doc "no longer makes gofumpt treat a single-line first declaration as multi-line"; and an assignment whose right-hand side is split by a comment "is now left alone". **golangci-lint lags gofumpt.** golangci-lint v2.13.2 vendors `mvdan.cc/gofumpt v0.11.0`, so `golangci-lint fmt` and a standalone v0.12.0 binary disagree on exactly the cases above. Do not use one as fixer and the other as gate. ## Installation ```bash # From source (recommended) go install mvdan.cc/gofumpt@latest # Pre-built binaries from GitHub Releases # Available for darwin/linux/windows on amd64/arm64 # Via gopls (no separate binary needed for editor use) # Configure your editor to tell gopls to use gofumpt formatting ``` ## CLI Usage ```bash gofumpt -w . # Format all Go files recursively, in-place gofumpt -l . # List files that differ from gofumpt style gofumpt -d main.go # Show diff without modifying (non-zero exit if diff exists) gofumpt -w main.go # Format single file in-place gofumpt -extra . # Enable all extra rules gofumpt -extra=group_params,clothe_returns . # Enable specific extra rules gofumpt -lang=go1.27 . # Specify language version gofumpt -modpath=github.com/org/repo . # Specify module path gofumpt -version # Print version cat main.go | gofumpt # Format from stdin ``` **Flags:** - `-w` - write result to file (instead of stdout) - `-l` - list files that differ - `-d` - display diff (non-zero exit if any diff, since v0.8.0) - `-extra` - enable extra rules. **Changed in v0.10.0:** "The `-extra` flag now accepts a comma-separated list of rule names to enable individual extra rules, rather than enabling all of them at once." Bare `-extra` still enables all of them; `-extra=group_params,clothe_returns,balance_calls` selects individually - `-e` - report all errors (not just the first 10 on different lines) - `-lang` - language version (default: from go.mod) - `-modpath` - module path (affects import grouping) - `-s` - hidden, always enabled (simplification). Note "the `-r` rewrite flag is removed in favor of `gofmt -r`, and the `-s` flag is hidden as it is always enabled" **Skipped automatically:** `vendor/`, `testdata/`, generated files (unless given as explicit args). Obeys `ignore` directives in go.mod (Go 1.25+). ## Default Rules (always applied) These are the formatting rules gofumpt enforces beyond gofmt: 1. **No empty lines around function bodies** - removes leading/trailing blank lines inside functions 2. **No empty lines around a lone statement in a block** - `if err != nil {\n\n\treturn err\n}` removes the blank line 3. **No empty lines before a simple error check** - no blank line between `foo, err := bar()` and `if err != nil {` 4. **No empty lines following an assignment operator** - `foo :=\n"bar"` becomes `foo := "bar"` 5. **Composite literals use newlines consistently** - if any element is on a new line, braces go on their own lines 6. **Empty field lists use a single line** - `struct {\n}` becomes `struct{}` 7. **std imports in a separate group at the top** - standard library imports grouped first, separated from third-party 8. **Short case clauses on a single line** - `case 'a', 'b',\n\t'c':` becomes `case 'a', 'b', 'c':` 9. **Multiline top-level declarations separated by empty lines** - two adjacent multi-line funcs get a blank line between them 10. **Single var declarations not grouped** - `var (\n\tfoo = "bar"\n)` becomes `var foo = "bar"` 11. **Contiguous top-level declarations grouped together** - consecutive `var x = ...` grouped into `var (...)` 12. **Simple var-declarations use short assignments** - `var s = "str"` becomes `s := "str"` 13. **`-s` simplification always on** - `[][]int{[]int{1}}` becomes `[][]int{{1}}` 14. **Octal literals use `0o` prefix** - `0755` becomes `0o755` (Go 1.13+ modules) 15. **Non-directive comments start with whitespace** - `//Foo` becomes `// Foo` (but `//go:noinline` stays) 16. **Composite literals: no leading/trailing empty lines** 17. **Field lists: no leading/trailing empty lines** 18. **Multi-line func params get `) {` on its own line** - trailing comma added for readability 19. **Redundant parentheses dropped** (v0.10.0) - "A new rule is introduced to drop unnecessary parentheses around expressions where the inner expression is unambiguous on its own, such as `f((3))`." Parentheses are kept where they carry meaning, such as on binary expressions, and around an expression starting with a composite literal like `(s{}.Foo())`, which needs them in an `if`/`for`/`switch` clause ## Extra Rules (opt-in with `-extra`) 1. **Group adjacent parameters with the same type** - `func Foo(bar string, baz string)` becomes `func Foo(bar, baz string)` 2. **Clothe naked returns** (`clothe_returns`) - `return` in a function with named results becomes `return err` with explicit values (added in v0.9.0, moved to `-extra` in v0.9.2) 3. **Balance multi-line calls** (`balance_calls`, v0.11.0) - matches the opening and closing parenthesis of a multi-line call in their use of newlines. Introduced as a default rule in v0.10.0 and walked back: "The multi-line function call rule introduced in v0.10.0 proved controversial, so it is now the extra rule `balance_calls`, disabled by default." It only moves the closing parenthesis to its own line when the opening parenthesis ends a line ## Editor Integration ### VS Code ```json { "go.useLanguageServer": true, "gopls": { "formatting.gofumpt": true } } ``` ### GoLand File Watchers: Settings > Tools > File Watchers > Add Custom Template - Program: path to `gofumpt` - Arguments: `-w $FilePath$` - Output: `$FilePath$` ### Neovim (lspconfig) ```lua require('lspconfig').gopls.setup({ settings = { gopls = { gofumpt = true } } }) ``` ### Vim (vim-go) ```vim let g:go_fmt_command="gopls" let g:go_gopls_gofumpt=1 ``` ### govim ```vim call govim#config#Set("Gofumpt", 1) ``` ### Helix ```toml # ~/.config/helix/languages.toml [language-server.gopls.config] "formatting.gofumpt" = true ``` ### Zed ```json { "lsp": { "gopls": { "initialization_options": { "gofumpt": true } } } } ``` ### Emacs (lsp-mode 8.0.0+) ```elisp (setq lsp-go-use-gofumpt t) ``` ### Emacs (eglot) ```elisp (setq-default eglot-workspace-configuration '((:gopls . ((gofumpt . t))))) ``` ### Sublime Text (ST4 with LSP) ```json { "lsp_format_on_save": true, "clients": { "gopls": { "enabled": true, "initializationOptions": { "gofumpt": true } } } } ``` ## golangci-lint v2 Integration In golangci-lint v2, gofumpt is a **formatter** (not a linter): ```yaml # .golangci.yml formatters: enable: - gofumpt settings: gofumpt: module-path: github.com/org/project extra: group-params: true clothe-returns: true balance-calls: false ``` Run: `golangci-lint fmt` Since golangci-lint v2.13.0 (which bundles gofumpt 0.11.0) the extra rules are selected individually - "`gofumpt`: from 0.9.2 to 0.11.0 (new options: `extra.group-params`, `extra.clothe-returns`, `extra.balance-calls`)". **`extra-rules: true` is deprecated, and it is not a neutral shorthand.** golangci-lint marks it `# Deprecated: use `extra` instead.` and warns on every run: `` `extra-rules` is deprecated, please use `extra.group-params` instead ``. More importantly it enables *all three* rules, `balance_calls` included - in gofumpt's own code `ExtraRules` calls `Extra.Set("true")`, whose branch sets `GroupParams`, `ClotheReturns` **and** `BalanceCalls`. Since `balance_calls` is the rule gofumpt deliberately demoted as controversial and disabled by default, `extra-rules: true` silently opts you back into it. Use the `extra:` map. ## Diagnostics Insert `//gofumpt:diagnose` in any Go file and run gofumpt - it rewrites the comment with version and config info: ```go //gofumpt:diagnose version: v0.12.0 flags: -lang=go1.27 -modpath=github.com/org/project ``` ## Go API ```go import "mvdan.cc/gofumpt/format" formatted, err := format.Source(src, format.Options{ LangVersion: "go1.26", ModulePath: "github.com/org/project", Extra: format.Extra{ GroupParams: true, ClotheReturns: true, BalanceCalls: false, }, }) ``` `Options.ExtraRules` is deprecated in favour of `Options.Extra`. To stay source-compatible across releases that add new extra rules, set them by name instead of by field - "Go API users who wish to avoid build errors in such cases can use the string API in [Extra.Set]". ## Recent Changes | Version | Date | Key Changes | |---------|------|-------------| | v0.12.0 | Sep 2026 | Based on Go 1.27's gofmt; **requires Go 1.26+**. Four import/blank-line fixes: std imports with comments stay put, no orphan empty line when a std import moves up, copyright headers no longer force a blank line after the first declaration, comment-split assignments left alone | | v0.11.0 | Jul 2026 | Multi-line call rule demoted to the `balance_calls` extra rule (disabled by default); stable single-pass output for a lone var next to a single-element var group | | v0.10.0 | May 2026 | Based on Go 1.26's gofmt; requires Go 1.25+. **Breaking:** `-extra` takes a comma-separated rule list instead of a boolean. New default rule dropping redundant parentheses | | v0.9.2 | Oct 2025 | "Clothe naked returns" moved to `-extra` flag | | v0.9.1 | Sep 2025 | Bugfix: comment directive detection | | v0.9.0 | Sep 2025 | Based on Go 1.25's gofmt. New "clothe naked returns" rule. Obeys go.mod `ignore`. Speed-up via x/mod/modfile | | v0.8.0 | Apr 2025 | Based on Go 1.24's gofmt. `-d` returns non-zero on diff | -
golangci-lint-reference.md 20.8 KB
# golangci-lint v2 Reference Latest: **v2.13.2** (2026-08-27). Requires `version: "2"` in config. **Go version floor:** "golangci-lint supports Go versions lower or equal to the Go version used to compile it." Go 1.27 support arrived in v2.13.0 ("🎉 go1.27 support"), so a Go 1.27 project needs v2.13 or newer - an older pin fails outright rather than degrading. `go install` of v2.13.2 itself requires Go 1.26. ## Installation ```bash # Binary (recommended) curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.2 # Homebrew brew install golangci-lint # Docker docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:v2.13.2 golangci-lint run # mise (uses the aqua backend, so it fetches the GitHub binary) mise use -g golangci-lint@2.13.2 # go install (not recommended - dependency conflicts possible) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 ``` **Upstream recommends binary installation and warns against the tools pattern:** "Using `go install`/`go get`, \"tools pattern\", and `tool` command/directives installations aren't guaranteed to work. We recommend using binary installation." Seven reasons are listed, the load-bearing one for shared repos being that "the dependencies of a tool can modify the dependencies of another tool or your project". There is a blunt "We don't recommend using `go tool`" on top. If you need it in `go.mod` anyway, isolate it behind a dedicated module file so it cannot perturb your project's graph - "the best approach is to use a dedicated module or module file to isolate golangci-lint from other tools or dependencies": ```bash go mod init -modfile=golangci-lint.mod github.com/org/repo/golangci-lint go get -tool -modfile=golangci-lint.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 go tool -modfile=golangci-lint.mod golangci-lint run go get -tool -modfile=golangci-lint.mod github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest # update ``` ## Commands ```bash golangci-lint run # Lint (default: ./...) golangci-lint run --fix # Lint and apply autofixes golangci-lint fmt # Format code (v2 feature) golangci-lint fmt --diff # Show formatting diff golangci-lint migrate # Migrate v1 config to v2 golangci-lint linters # List enabled linters golangci-lint help linters # List all available linters golangci-lint formatters # List enabled formatters golangci-lint config path # Show which config file is used golangci-lint cache clean # Clear analysis cache (fixes phantom issues from stale results) golangci-lint cache status # Show cache directory and size golangci-lint custom # Build a binary with module plugins (.custom-gcl.yml) golangci-lint version # Print version golangci-lint run --fast-only # Run fast linters only (for editors) golangci-lint run --default=none --enable=govet # Run specific linters ``` ## Config File Structure Config file: `.golangci.yml` (searched in CWD, then parent dirs, then home). JSON Schema: use the **versioned** URL matching your binary, e.g. `https://golangci-lint.run/jsonschema/golangci.v2.13.jsonschema.json`. The unversioned `golangci.jsonschema.json` tracks master and already contains options your released binary rejects, so validating against it produces false positives. `golangci-lint config verify` against the installed binary is the authoritative check. `.golangci.reference.yml` in the repo lists every supported option with descriptions and defaults - "There is a `.golangci.reference.yml` file with all supported options, their descriptions, and default values." **Cache isolation:** golangci-lint honours `GOLANGCI_LINT_CACHE`. Give each git worktree its own value so a deleted branch's cached results cannot resurface as issues in files that no longer exist. The cache does not reliably invalidate on config, tool-version, or dependency changes, so if a phantom issue keeps returning, fold those inputs into the cache path rather than clearing by hand each time. **Cache isolation does not buy you concurrency.** The run lock is a single file in the system temp dir - `filepath.Join(os.TempDir(), "golangci-lint.lock")` - so two runs collide no matter how their caches are separated. A second run retries for five seconds and then exits with `parallel golangci-lint is running`. Two knobs change this: ```yaml run: allow-parallel-runners: true # "Allow multiple parallel golangci-lint instances running." Drops the lock. allow-serial-runners: true # "Allow multiple golangci-lint instances running, but serialize them around a lock." Waits instead of failing. ``` Reach for one of them before putting `golangci-lint fmt` and `golangci-lint run` in the same `just` recipe under `[parallel]`, or in concurrent CI steps - otherwise the failure lands on green code and looks like a lint bug. **Debugging the nolint filter.** When nolintlint claims a live `//nolint` directive is unused, `GL_DEBUG=nolint_filter` prints what the filter actually received - the fastest way to tell a stale cache from a genuinely dead suppression before you delete a real one. Other useful keys: `GL_DEBUG=exec` (the lock file), `pkgcache`, `linters_context`, `enabled_linters`. ```yaml version: "2" # REQUIRED run: timeout: 5m # Default: 0 (disabled in v2) tests: true # Include test files build-tags: [] go: "" # Default: from go.mod concurrency: 0 # 0 = auto (CPU count) relative-path-mode: cfg # cfg | gomod | gitroot | wd issues-exit-code: 1 # Exit code when issues were found modules-download-mode: readonly # mod | readonly | vendor allow-parallel-runners: false # Drop the global run lock allow-serial-runners: false # Queue on the lock instead of failing enable-build-vcs: false # Default false, which implies `-buildvcs=false` linters: default: standard # standard | all | none | fast enable: [...] disable: [...] settings: # Per-linter config (was top-level linters-settings in v1) govet: enable: [shadow] revive: enable-all-rules: true exclusions: generated: strict # strict | lax | disable warn-unused: true presets: # NOT enabled by default in v2 - comments - std-error-handling - common-false-positives rules: - path: _test\.go linters: [gocyclo, errcheck, dupl, gosec] paths: - third_party$ - vendor$ formatters: enable: [gofumpt, goimports] settings: gofumpt: extra-rules: true gci: sections: [standard, default, "prefix(github.com/myorg/myrepo)"] exclusions: generated: strict issues: max-issues-per-linter: 50 # 0 = unlimited max-same-issues: 3 # 0 = unlimited new: false new-from-merge-base: "" # e.g., "main" fix: false output: formats: text: path: stdout print-linter-name: true colors: true sort-order: [linter, file] show-stats: true path-mode: "" # "abs" shows absolute paths instead of relative ones path-prefix: "" # Prepended to every reported path severity: default: "" rules: - linters: [dupl] severity: info ``` ## Default Linters (the "standard" set) Enabled when `default: standard` (the default): 1. **errcheck** - unchecked errors 2. **govet** - suspicious constructs (like `go vet`) 3. **ineffassign** - unused assignments 4. **staticcheck** - comprehensive static analysis (includes gosimple + stylecheck in v2) 5. **unused** - unused code ## Linter Catalog by Category ### Bug Detection | Linter | Description | Autofix | |--------|-------------|---------| | bodyclose | HTTP response body not closed | | | contextcheck | Non-inherited context usage | | | durationcheck | Two durations multiplied together | | | errcheck | Unchecked errors (default) | | | errchkjson | Types passed to json encoding | | | errorlint | Go 1.13+ error wrapping issues | Yes | | exhaustive | Enum switch exhaustiveness | | | fatcontext | Nested contexts in loops | Yes | | gosec | Security problems | | | govet | Suspicious constructs (default) | Yes | | makezero | Slices with non-zero initial length | | | musttag | Field tags in marshaled structs | | | nilerr | Returns nil when err is not nil | | | nilnesserr | err != nil but returns different nil error | | | noctx | Missing context.Context usage | | | rowserrcheck | Rows.Err not checked | | | sqlclosecheck | sql.Rows/Stmt not closed | | | staticcheck | Comprehensive static analysis (default) | Yes | | testifylint | Testify usage issues | Yes | ### Performance | Linter | Description | Autofix | |--------|-------------|---------| | fatcontext | Context allocation in loops | Yes | | perfsprint | Faster alternatives to fmt.Sprintf | Yes | | prealloc | Slice pre-allocation opportunities | | ### Style & Code Quality | Linter | Description | Autofix | |--------|-------------|---------| | copyloopvar | Loop variable copies | Yes | | dupl | Duplicate code fragments | | | dupword | Duplicate words in source | Yes | | err113 | Error handling expressions | Yes | | errname | Sentinel error naming conventions | | | exptostd | Replace x/exp with stdlib | Yes | | goconst | Repeated strings that could be constants | | | gocritic | Bugs, performance, style diagnostics | Yes | | godot | Comments ending in period | Yes | | intrange | Integer range in for loops | Yes | | mirror | bytes/strings mirror patterns | Yes | | misspell | Misspelled English words | Yes | | modernize | Suggests modern Go language features | | | nakedret | Naked returns | Yes | | nestif | Deeply nested if statements | | | nolintlint | Ill-formed nolint directives | Yes | | nonamedreturns | Named returns | | | predeclared | Shadowing predeclared identifiers | | | revive | Fast, configurable meta-linter | | | sloglint | log/slog code style | Yes | | thelper | Missing t.Helper() in test helpers | | | unconvert | Unnecessary type conversions | | | unparam | Unused function parameters | | | usestdlibvars | Use stdlib variables/constants | Yes | | usetesting | Use testing package replacements | Yes | | wastedassign | Wasted assignments | | | whitespace | Unnecessary newlines | Yes | | wrapcheck | Error wrapping from external packages | | | wsl_v5 | Whitespace/cuddling style (replaces `wsl`) | | ### Also Available (not in the sets above) | Linter | Description | Autofix | |--------|-------------|---------| | arangolint | ArangoDB query issues, incl. injection | | | canonicalheader | Non-canonical HTTP header keys | Yes | | clickhouselint | ClickHouse driver misuse (v2.12.0+) | | | embeddedstructfieldcheck | Embedded-field placement in structs | | | funcorder | Constructor/method ordering within a file | | | gochecksumtype | Exhaustiveness for sum types | | | godoclint | Godoc comment conventions | | | gomodguard_v2 | Allow/blocklist direct module dependencies | | | iface | Interface misuse, incl. unused methods | | | iotamixing | Mixed iota and explicit values in a const block | | | nilnil | Returning both a nil value and a nil error | | | noinlineerr | Inline `if err := f(); err != nil` declarations | | | protogetter | Direct proto field access instead of getters | Yes | | recvcheck | Mixed pointer/value receivers on one type | | | spancheck | OpenTelemetry/Census span mistakes | | | tagalign | Struct tag alignment | Yes | | unqueryvet | `SELECT *`, N+1 queries, SQL injection, tx leaks | | ### Deprecated Names | Deprecated | Replacement | Since | |-----------|-------------|-------| | `wsl` | `wsl_v5` | v2.2.0 | | `gomodguard` | `gomodguard_v2` | v2.12.0 | | `exhaustruct` | `exhaustruct_v5` | v2.13.0 | Deprecated names still resolve but will be removed; `golangci-lint help linters` marks them `[deprecated]`. ## Recommended Linter Sets ### Minimal (large existing codebases) ```yaml linters: default: standard enable: - bodyclose - errorlint - gosec - noctx - sqlclosecheck ``` ### Comprehensive (new projects - recommended) ```yaml linters: default: standard enable: - bodyclose - copyloopvar - dupl - durationcheck - err113 - errname - errorlint - exhaustive - exptostd - fatcontext - goconst - gocritic - gosec - intrange - misspell - modernize - musttag - nakedret - nestif - nilerr - noctx - nolintlint - nonamedreturns - perfsprint - prealloc - revive - sqlclosecheck - testifylint - thelper - unconvert - unparam - usestdlibvars - usetesting - wastedassign - whitespace - wrapcheck ``` ### Maximum (enable all, disable noisy ones) ```yaml linters: default: all disable: - exhaustruct_v5 # Too strict for most projects (v2.13.0+ name; was `exhaustruct`) - gochecknoglobals # Impractical for many codebases - gochecknoinits # Too restrictive - ireturn # Controversial - varnamelen # Too opinionated - mnd # Very noisy (magic numbers) - lll # Line length is editor config territory - funlen # Arbitrary length limits - godox # FIXME/TODO are normal in active dev - wsl_v5 # Very opinionated whitespace rules ``` ## Nolint Directive Syntax ```go // Suppress specific linters on this line: var bad int //nolint:revive,unused // Suppress all linters: var bad int //nolint:all // Suppress for a function/block: //nolint:gocyclo func complexFunction() { ... } // Suppress for entire file (before package): //nolint:unparam package pkg // With justification (recommended, enforced by nolintlint): var x int //nolint:revive // legacy code, scheduled for cleanup ``` **Syntax rules** - nolint is a Go directive, not a comment: - NO space between `//` and `nolint` - NO space between `nolint` and `:` - NO space between `:` and linter names Valid: `//nolint:xxx` | Invalid: `// nolint`, `//nolint :xxx`, `//nolint: xxx` ## Exclusion Presets ```yaml linters: exclusions: presets: - comments # Suppress exported-should-have-comment checks - std-error-handling # Suppress errcheck on stdout/stderr/Close/Flush - common-false-positives # Suppress common gosec false positives - legacy # Suppress legacy govet/staticcheck patterns ``` Not enabled by default in v2 - you must opt in explicitly. `path-except` / `paths-except` are the inverses, letting a linter run *only* on matching files - "Run some linter only for test files by excluding its issues for everything else. - path-except: `_test\.go`". ## Output Formats `output.formats.text` is only one of nine. All can be written simultaneously, each to its own path: ```yaml output: formats: text: path: stdout print-linter-name: true colors: true sarif: path: golangci-lint.sarif junit-xml: path: golangci-lint-report.xml ``` Available: `text`, `json`, `tab`, `html`, `checkstyle`, `code-climate`, `junit-xml`, `teamcity`, `sarif`. `sarif` is the path into GitHub code scanning - upload the file with `github/codeql-action/upload-sarif` and findings appear as annotations in the Security tab. `junit-xml` and `checkstyle` cover most other CI systems. ## Incremental Adoption Beyond the Action's `only-new-issues`, the binary can restrict reporting to changed code: ```bash golangci-lint run --new-from-merge-base=main # only issues absent from the merge base golangci-lint run --new-from-rev=HEAD~1 # only issues introduced since a revision golangci-lint run --new-from-patch=changes.patch golangci-lint run --whole-files # report all issues in a changed file, not just changed lines golangci-lint run --enable-only=errcheck # run exactly one linter, ignoring config ``` The same knobs exist in config under `issues.new`, `issues.new-from-merge-base`, and `issues.new-from-rev`. ## Module Plugins Linters not bundled with golangci-lint can be compiled into a custom binary. Define the build in `.custom-gcl.yml`, then "Run the command `golangci-lint custom`" to produce it: ```yaml # .custom-gcl.yml version: v2.13.2 name: custom-golangci-lint destination: ./bin plugins: - module: github.com/example/my-linter version: v1.0.0 ``` The resulting binary reads the same `.golangci.yml` and exposes the plugin's linters alongside the built-in set. Module plugins are one of two plugin systems. **Go plugins** (`.so` files loaded at runtime, built with `go build -buildmode=plugin`) are the other; they avoid the rebuild step but are fragile across toolchain versions and unsupported on Windows. Prefer module plugins unless you specifically need runtime loading. ## Editor and Shell Integration Beyond the editor settings below, two integrations are easy to miss: - **`golangci-lint-langserver`** exposes the linter over LSP for NeoVim, Vim, and Emacs, so findings appear inline without a save-and-run cycle. - **`golangci-lint completion`** generates shell completion: "Golangci-lint can generate Bash, fish, PowerShell, and Zsh completion files." ## Other CI Systems The GitHub Action is the best-supported path, but upstream documents GitLab CI and Buildkite recipes as well. The portable shape is the install script plus a cached `$GOLANGCI_LINT_CACHE`; use the `junit-xml` or `checkstyle` output format to surface findings natively in those systems. ## Formatters Section (v2) Formatters are separate from linters in v2. They have their own `enable`, `settings`, and `exclusions`. Available formatters: `gci`, `gofmt`, `gofumpt`, `goimports`, `golines`, `swaggo` (added v2.2.0) ```yaml formatters: enable: - gofumpt - goimports settings: gofumpt: extra-rules: true # all extra rules # or select individually (v2.13.0+, gofumpt 0.11.0): # extra: # group-params: true # clothe-returns: true # balance-calls: false goimports: local-prefixes: github.com/myorg/myrepo ``` Run: `golangci-lint fmt`, `golangci-lint fmt --diff`, or `golangci-lint fmt --diff-colored`. Do not pair `golangci-lint fmt` as the CI gate with a standalone `gofumpt -w` as the fixer - they can disagree on the same file, so the gate fails on code the fixer just formatted. Pick one for both roles. ## GitHub Actions Official action: `golangci/golangci-lint-action@v9` ```yaml - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: stable - uses: golangci/golangci-lint-action@v9 with: version: v2.13 # only-new-issues: true # For incremental adoption ``` Keep `version:` at or above the Go version `setup-go` resolves. With `go-version: stable` that is the newest Go release, so a pin left behind after a Go major bump breaks the job. Key options: | Option | Default | Description | |--------|---------|-------------| | `version` | *(optional)* | e.g. `v2.13`, `v2.13.2`, or `latest`. Declared `required: false` in `action.yml` - omit it and the action resolves a default | | `version-file` | - | Read the version from `.golangci-lint-version` or `.tool-versions` | | `install-only` | false | Install the binary without running it | | `only-new-issues` | false | Show only new issues on PRs | | `verify` | true | Validate config against JSON Schema | | `cache-invalidation-interval` | 7 | Days before cache refresh | | `skip-save-cache` | false | Restore but don't save cache | ## Editor Integration **VS Code:** ```json { "go.lintTool": "golangci-lint", "go.lintFlags": ["--path-mode=abs", "--fast-only"] } ``` **GoLand:** Built-in support since 2025.1 for both v1 and v2. ## v2 Migration from v1 Key breaking changes in v2.0.0 (March 2025): - `version: "2"` required in config - `staticcheck`, `gosimple`, `stylecheck` merged into `staticcheck` - `linters-settings:` moved under `linters.settings:` - `issues.exclude-rules` moved to `linters.exclusions.rules` - Formatters moved to `formatters:` section - `disable-all: true` replaced by `default: none` - No exclusions by default (must use `presets:`) - Many deprecated linters removed (`deadcode`, `golint`, `varcheck`, etc.) Run `golangci-lint migrate` to auto-convert v1 configs. ## Notable v2.x Additions | Version | Key Additions | |---------|--------------| | v2.1.0 | `funcorder` linter, colored diff for `fmt` | | v2.2.0 | `noinlineerr` linter, `wsl_v5` replaces deprecated `wsl` | | v2.4.0 | Go 1.25 support | | v2.5.0 | `godoclint`, `unqueryvet`, `iotamixing` linters | | v2.6.0 | `modernize` analyzer suite | | v2.9.0 | Go 1.26 support | | v2.11.0 | New gosec rules, revive `package-naming` (⚠️ breaking: package checks moved out of `var-naming`) | | v2.12.0 | `clickhouselint` linter, `gomodguard_v2` major bump, JSON schema embedded in the binary | | v2.13.0 | **Go 1.27 support**; `exhaustruct` deprecated in favour of `exhaustruct_v5`; gofumpt 0.11.0 with granular `extra.*` options; `govet-modernize` 0.49.0 | | v2.13.1 | Linter bug fixes | | v2.13.2 | Cache-entropy fix; linter deps bumped (`staticcheck` 0.8.1, `iface` 1.5.1, `unparam`); `canonicalheader` moved to a temporary fork. No config-schema change (current release) | -
gotestsum-reference.md 8.1 KB
# gotestsum Reference Latest: **v1.13.0** (September 2025; still current as of 2026-09). Module: `gotest.tools/gotestsum`. Requires Go 1.24+. A test runner that wraps `go test -json` with readable output, watch mode, JUnit XML, and rerun capabilities. ## Installation ```bash go install gotest.tools/gotestsum@latest # Or run without installing go run gotest.tools/gotestsum@latest # Homebrew brew install gotestsum ``` ## Basic Usage ```bash # Run all tests (equivalent to: go test -json ./...) gotestsum # With format and race detection gotestsum --format testname -- -race ./... # Everything after -- is passed to go test gotestsum -- -tags=integration -count=1 ./... # Single package gotestsum -- ./internal/user # Specific test gotestsum -- -run TestMyFunc ./... # With coverage gotestsum -- -race -coverprofile=cover.out -covermode=atomic ./... ``` ## Output Formats Set via `--format` flag or `GOTESTSUM_FORMAT` env var. Default: `pkgname`. | Format | Description | |--------|-------------| | `dots` | Print a character for each test | | `dots-v2` | One package per line | | `pkgname` | One line per package (default) | | `pkgname-and-test-fails` | One line per package + failed test output | | `testname` | One line per test and package | | `testdox` | Sentence for each test | | `github-actions` | testname with GitHub Actions log grouping | | `standard-quiet` | Standard `go test` format | | `standard-verbose` | Standard `go test -v` format | **Format icons** (`--format-icons` or `GOTESTSUM_FORMAT_ICONS`): - `default` - unicode (check, X) - `hivis` - high visibility unicode - `text` - PASS, SKIP, FAIL - `codicons` / `octicons` / `emoticons` - Nerd Fonts Additional: `--format-hide-empty-pkg` hides packages with no tests. ## Watch Mode ```bash # Basic watch gotestsum --watch --format testname # With screen clearing (v1.13.0+) gotestsum --watch --watch-clear --format testname # Watch with chdir (multi-module repos) gotestsum --watch --watch-chdir ``` **Interactive keys in watch mode:** - `r` - rerun tests for previous event - `u` - rerun with `-update` flag (golden files) - `d` - debug with delve - `a` - run all tests (`./...`) - `l` - rescan directories for new `.go` files ## JUnit XML Output ```bash # Basic JUnit output gotestsum --junitfile unit-tests.xml # With CI-friendly format gotestsum --format github-actions --junitfile unit-tests.xml -- -race ./... # Customize naming gotestsum --junitfile unit-tests.xml \ --junitfile-testsuite-name relative \ --junitfile-testcase-classname short # Project name and clean output gotestsum --junitfile unit-tests.xml \ --junitfile-project-name "my-service" \ --junitfile-hide-empty-pkg \ --junitfile-hide-skipped-tests ``` Name format options (`--junitfile-testsuite-name`, `--junitfile-testcase-classname`): - `full` (default) - full package path - `relative` - relative to repo root - `short` - base package name ## Rerunning Failed Tests ```bash # Rerun failed tests up to 2 times gotestsum --rerun-fails --packages="./..." -- -count=1 # Custom retry count and threshold gotestsum --rerun-fails=3 \ --rerun-fails-max-failures=5 \ --packages="./..." \ -- -count=1 # Rerun root test when subtests fail gotestsum --rerun-fails --rerun-fails-run-root-test --packages="./..." # Abort rerun on data race (v1.12.3+) gotestsum --rerun-fails --rerun-fails-abort-on-data-race --packages="./..." ``` ## Tools ### Find Slowest Tests ```bash gotestsum --format dots --jsonfile test.json ./... gotestsum tool slowest --jsonfile test.json --threshold 500ms ``` ### Auto-skip Slow Tests ```bash go test -json -short ./... | gotestsum tool slowest --skip-stmt "testing.Short" --threshold 200ms ``` ### CI Matrix Partitioning ```bash # Partition tests across CI jobs based on timing data echo -n "matrix=" >> $GITHUB_OUTPUT go list ./... | gotestsum tool ci-matrix --timing-files ./*.log --partitions 4 >> $GITHUB_OUTPUT ``` ## Custom Commands with `--raw-command` `--raw-command` tells gotestsum to run your command verbatim instead of prepending `go test -json`. The contract is strict: "The stdout produced by the script must only contain the `test2json` output, or `gotestsum` will fail." Send anything else to stderr. This is how you run an already-compiled test binary - useful for cross-compiled or long-lived test binaries you do not want to rebuild: ```bash gotestsum --raw-command -- go tool test2json -t -p pkgname ./binary.test -test.v ``` `-p` supplies the package name that `test2json` cannot infer from a bare binary, and `-t` adds timestamps. ## Post-Run Commands ```bash # Desktop notifications go install gotest.tools/gotestsum/contrib/notify@latest gotestsum --post-run-command notify # Print slowest tests after run gotestsum --jsonfile tmp.json \ --post-run-command "bash -c 'gotestsum tool slowest --num 10 --jsonfile tmp.json'" ``` Post-run environment variables: - `GOTESTSUM_ELAPSED` - test run time - `TESTS_TOTAL`, `TESTS_FAILED`, `TESTS_SKIPPED`, `TESTS_ERRORS` ## All CLI Flags ``` --format, -f string Output format (default "pkgname") --format-hide-empty-pkg Hide empty packages --format-icons string Icon set --raw-command Don't prepend 'go test -json' --no-color Disable color (auto-detected in CI) --max-fails int Stop after N failures --jsonfile string Write all TestEvents to file --jsonfile-timing-events string Write only pass/skip/fail events to the file --junitfile string Write JUnit XML --junitfile-testsuite-name Name format: full|relative|short --junitfile-testcase-classname Classname format: full|relative|short --junitfile-project-name Project name in XML --junitfile-hide-empty-pkg Omit empty packages in XML --junitfile-hide-skipped-tests Omit skipped tests in XML --hide-summary string Hide: skipped,failed,errors,output,all --rerun-fails int Rerun failed tests (default max 2) --rerun-fails-max-failures Skip rerun if initial failures > N (default 10) --rerun-fails-run-root-test Rerun root test case for subtest failures --rerun-fails-abort-on-data-race Stop rerun on data race --rerun-fails-report string Write a report of the reruns to the file --ignore-non-json-output-lines Send non-JSON stdout lines to stderr --watch Watch .go files and rerun --watch-chdir cd to modified file's dir --watch-clear Clear screen on rerun --packages list Space-separated package list --post-run-command command Run after tests complete --debug Enable debug logging --version Show version ``` ## Environment Variables | Variable | Purpose | |----------|---------| | `GOTESTSUM_FORMAT` | Default output format | | `GOTESTSUM_FORMAT_ICONS` | Icon set | | `GOTESTSUM_JUNITFILE` | JUnit output path | | `GOTESTSUM_JUNITFILE_PROJECT_NAME` | Project name in JUnit | | `GOTESTSUM_JSONFILE` | JSON output path | | `TEST_DIRECTORY` | Default test directory (instead of `./...`) | | `GOVERSION` | Go version for JUnit XML when `go` is not on PATH | ## Justfile Recipes ```just # Run all tests test: gotestsum --format testname -- -race ./... # Tests with coverage test-cov: gotestsum --format testname -- -race -coverprofile=cover.out -covermode=atomic ./... go tool cover -func=cover.out # CI output with JUnit XML test-ci: gotestsum --format github-actions \ --junitfile unit-tests.xml \ --junitfile-hide-empty-pkg \ -- -race -count=1 ./... # Watch mode test-watch: gotestsum --watch --watch-clear --format testname # Rerun flaky tests test-flaky: gotestsum --format testname \ --rerun-fails=3 \ --rerun-fails-max-failures=5 \ --packages="./..." -- -count=1 ``` ## Recent Changes | Version | Date | Key Changes | |---------|------|-------------| | v1.13.0 | Sep 2025 | `--watch-clear` flag, Go test attributes support (`t.Attr`, Go 1.25+) | | v1.12.3 | Jun 2025 | `--rerun-fails-abort-on-data-race` flag | | v1.12.2 | May 2025 | `--junitfile-hide-skipped-tests` flag | | v1.12.1 | Mar 2025 | Go 1.24 compatibility, JUnit `skipped` attribute | | v1.12.0 | May 2024 | `--format-icons` flag with Nerd Fonts | -
justfile-reference.md 15.2 KB
# Justfile Reference for Go Projects `just` is a command runner (not a build system). It runs recipes defined in a `Justfile`. Latest: **1.58.0** (2026-08-03). ## Installation ```bash # macOS brew install just # Cargo cargo install just # Pre-built binaries # https://github.com/casey/just/releases ``` ## Core Syntax ```just set shell := ["bash", "-euo", "pipefail", "-c"] # Strict bash: errexit, undefined vars, pipefail set dotenv-load := true # Load .env file # Recipe with doc comment recipe-name: command1 command2 # Recipe with arguments build target="./cmd/myapp": go build -o myapp {{ target }} # Recipe with dependencies check: fmt-check lint test @echo "All checks passed" ``` **Key rules:** - Indent recipe bodies consistently - **either** tabs or spaces works (unlike Makefiles, which demand tabs), but the indentation must be uniform within a recipe. `set indentation` makes the project's choice explicit. The templates in this reference use spaces - Each line runs in a separate shell (use `&&` or `\` to chain) - `@` prefix suppresses command echo - `#` comments above a recipe become its doc string ## Variables ```just binary := "myapp" # Simple version := `git describe --tags --always` # Backtick (shell command) export DATABASE_URL := env("DATABASE_URL", "") # Environment with default ``` ## Parameters ```just # Required parameter migrate-create name: migrate create -ext sql -dir migrations -seq {{ name }} # Default parameter test *args="./...": gotestsum --format testname -- -race {{ args }} # Variadic run *args: go run ./cmd/myapp {{ args }} ``` ## Dependencies ```just # Prior dependencies (run before recipe) coverage: test-cov go tool cover -html=coverage.out # With arguments deploy env: (build env) ./scripts/deploy.sh {{ env }} ``` ## Recipe Attributes ```just # Group recipes in --list output [group('quality')] lint: golangci-lint run ./... # Hide from --list [private] default: @just --list --unsorted # Require confirmation before running [confirm("Drop all tables?")] db-drop: migrate -path migrations -database "$DATABASE_URL" drop -f # Platform-specific [linux] install: sudo cp myapp /usr/local/bin/ [macos] install: cp myapp /usr/local/bin/ # Documented recipe (alternative to comment) [doc("Run all tests with race detection")] test: gotestsum --format testname -- -race ./... # Run the recipe from a fixed directory, whatever the invocation dir [working-directory('backend')] migrate-up: migrate -path migrations -database "$DATABASE_URL" up # Set an env var for this recipe only [env('CGO_ENABLED', '0')] build-static: go build -o myapp ./cmd/myapp # Run this recipe's dependencies concurrently [parallel] check-all: lint test vuln # Print a timestamp before each command [timestamp] slow-task: go test -run TestBigIntegration ./... # Treat the body as a script for one interpreter (no per-line shells) [script('bash', '-euo', 'pipefail', '-c')] release: VERSION=$(git describe --tags --always) goreleaser release --clean ``` ### Recipe flags with `[arg(...)]` Turns positional parameters into real command-line options - "Require values of argument `ARG` to be passed as `--LONG` option." ```just [arg('env', long='environment', short='e')] deploy env='staging': ./scripts/deploy.sh {{ env }} ``` Invoke as `just deploy --environment prod` instead of `just deploy prod`. ## Settings ```just set shell := ["bash", "-euo", "pipefail", "-c"] # Shell and flags set dotenv-load := true # Auto-load .env set export := true # Export all variables as env vars set quiet := true # Suppress command echo by default set positional-arguments := true # Pass args as $1, $2, etc. set dotenv-path := ".env.local" # Load a specific env file set dotenv-required := true # Fail if the env file is missing set dotenv-override := true # .env wins over the ambient environment set working-directory := "backend" # Default dir for every recipe set indentation := " " # Make the tabs-vs-spaces choice explicit set minimum-version := "1.58.0" # Error if `just` is older than this set script-interpreter := ["bash", "-euo", "pipefail"] # Default for [script] recipes set fallback := true # Search parent directories for a recipe set no-exit-message := true # Suppress just's own error line on failure set dotenv-command := 'sops -d .enc.env' # Run a command, load its output as the env file set default-script := true # Recipes default to script mode instead of shell mode ``` This is a catalog, not a copy-pasteable header - `dotenv-command` and `dotenv-load` are mutually exclusive, and `just` rejects a file setting both. `set minimum-version` is worth adding to any Justfile that uses recent attributes: without it, an older `just` fails with a confusing parse error instead of a version message. ## Shebang Recipes Run a recipe with a different interpreter: ```just # Python script [group('tools')] generate-docs: #!/usr/bin/env python3 import json with open("api.json") as f: spec = json.load(f) print(f"Found {len(spec['paths'])} endpoints") # Bash with strict mode [group('ci')] release: #!/usr/bin/env bash set -euo pipefail VERSION=$(git describe --tags --always) echo "Releasing $VERSION" goreleaser release --clean ``` ## Conditional Logic ```just # Ternary test-cmd := if env("CI", "") != "" { "gotestsum --format github-actions" } else { "gotestsum --format testname" } test: {{ test-cmd }} -- -race ./... # In-recipe conditionals (bash) deploy env: #!/usr/bin/env bash if [ "{{ env }}" = "prod" ]; then echo "Deploying to production" else echo "Deploying to {{ env }}" fi ``` ## Built-in Functions | Function | Description | |----------|-------------| | `env("KEY", "default")` | Read environment variable | | `home_directory()` | User home directory | | `os()` | Operating system | | `arch()` | CPU architecture | | `justfile_directory()` | Directory containing the Justfile | | `invocation_directory()` | Directory where `just` was invoked | | `trim(s)` | Trim whitespace | | `replace(s, from, to)` | String replacement | | `uppercase(s)` / `lowercase(s)` | Case conversion | ## Complete Go Project Justfile ```just set shell := ["bash", "-euo", "pipefail", "-c"] set dotenv-load := true export PATH := home_directory() + "/go/bin:" + env('PATH') binary := "myapp" [private] default: @just --list --unsorted # ── Code Quality ────────────────────────────────────────── # Format all Go code [group('quality')] fmt: golangci-lint fmt ./... # Check formatting (CI-safe, non-zero exit on diff) # Gate with the same tool that fixes - see the two-formatter footgun in SKILL.md [group('quality')] fmt-check: golangci-lint fmt --diff ./... # Run linter [group('quality')] lint: golangci-lint run ./... # Run linter with auto-fix [group('quality')] lint-fix: golangci-lint run --fix ./... # Run vulnerability check [group('quality')] vuln: govulncheck ./... # ── Testing ─────────────────────────────────────────────── # Run all tests with race detection [group('test')] test *args="./...": gotestsum --format testname -- -race {{ args }} # Run tests with coverage [group('test')] test-cov: gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./... go tool cover -func=coverage.out # Open coverage report in browser [group('test')] coverage: test-cov go tool cover -html=coverage.out # Run integration tests [group('test')] test-integration: gotestsum --format testname -- -race -tags=integration ./... # Watch tests during development [group('test')] test-watch: gotestsum --watch --watch-clear --format testname # Run benchmarks [group('test')] bench: go test -bench=. -benchmem ./... # ── Build ───────────────────────────────────────────────── # Build the binary [group('build')] build: go build -o {{ binary }} ./cmd/{{ binary }} # Build optimized release binary [group('build')] build-release: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }} # ── Dependencies ────────────────────────────────────────── # Tidy and verify modules [group('deps')] tidy: go mod tidy go mod verify # Run code generators [group('deps')] generate: go generate ./... # Update all dependencies [group('deps')] update-deps: go get -u ./... go mod tidy # ── Database ────────────────────────────────────────────── # Apply all pending migrations [group('db')] migrate-up: migrate -path migrations -database "$DATABASE_URL" up # Revert last migration [group('db')] migrate-down: migrate -path migrations -database "$DATABASE_URL" down 1 # Create a new migration [group('db')] migrate-create name: migrate create -ext sql -dir migrations -seq {{ name }} # Show migration version [group('db')] migrate-version: migrate -path migrations -database "$DATABASE_URL" version # ── CI ──────────────────────────────────────────────────── # Full CI gate [group('ci')] check: fmt-check lint test @echo "All checks passed" # CI test output with JUnit XML [group('ci')] test-ci: gotestsum --format github-actions \ --junitfile unit-tests.xml \ --junitfile-hide-empty-pkg \ -- -race -count=1 ./... # Clean build artifacts [group('ci')] clean: go clean rm -f {{ binary }} coverage.out unit-tests.xml ``` ## Lefthook Integration Lefthook can call `just` recipes in hooks: ```yaml # lefthook.yml pre-commit: commands: fmt: run: just fmt lint: glob: "*.go" run: just lint pre-push: commands: check: run: just check ``` Note the direction of the dependency: git invokes the hook binary directly, so lefthook must be installed and its config must sit at the repo root or in `.config/` for any of this to fire. A `just` recipe cannot rescue a misplaced `lefthook.yml`. Useful in a `check` recipe: ```just [group('ci')] hooks-check: lefthook validate # config is well-formed lefthook dump # print the merged effective config ``` ## Importing Recipes Split large Justfiles: ```just # Justfile import 'just/db.just' import 'just/docker.just' ``` `import` splices recipes into the current namespace; `mod` keeps them namespaced, so recipes are invoked as `just db migrate-up`: ```just mod db 'just/db.just' mod docker ``` ```just # just/db.just [group('db')] migrate-up: migrate -path migrations -database "$DATABASE_URL" up ``` ## Running ```bash just # Run default recipe (list all) just test # Run specific recipe just test ./pkg/... # Recipe with argument just --list # List all recipes just --list --unsorted # List in file order just --summary # One-line summary of each recipe just --evaluate # Show all variable values just --dry-run test # Show what would run just -f path/Justfile # Use specific Justfile just --fmt # Format the Justfile in place just --fmt --check # Exit non-zero if the Justfile is not formatted (CI gate) just --jobs 4 # Cap parallelism for [parallel] dependencies ``` `just --fmt --check` is a natural addition to the `check` recipe - "Run `--fmt` in 'check' mode. Exits with 0 if justfile is formatted correctly." ## Go Tooling Traps in Recipes Three ways a recipe that reads correctly still fails: **`go tool` is scoped to the current directory's module.** Per `go help tool`, "additional tools may be defined in the go.mod of the current module" - so in a monorepo, a recipe invoked from the repo root fails with `go: no such tool "golangci-lint"` even though the tool is tracked in the submodule. Pin the directory on the recipe: ```just [group('quality')] [working-directory('services/api')] lint: go tool golangci-lint run ./... ``` **A version-manager shim is a fourth install path**, alongside binary, Homebrew, and `go install`. If a tool is on PATH via mise, asdf, or similar and no version is pinned for the project, the recipe fails inside the shim rather than in the tool - `mise ERROR No version is set for shim: golangci-lint` - which reads like a Justfile bug. Pin the tool version in the version manager's config, or call an absolute path. **`GOFLAGS=-trimpath` lets worktrees share one warm cache.** `-trimpath` "remove[s] all file system paths from the resulting executable", which also makes the build and test cache keys path-independent. Without it, every git worktree recompiles the whole dependency tree (with `-race`, expensively) because its absolute paths differ: ```just export GOFLAGS := "-trimpath" ``` Set it at the top of the Justfile so `build`, `test`, and the linter's own package loading all share cache entries across worktrees. ## Tips - Use `set shell := ["bash", "-euo", "pipefail", "-c"]` to catch command failures, undefined variables, and broken pipelines - Group related recipes with `[group('name')]` for organized `--list` output - Use `[private]` for helper recipes that shouldn't appear in `--list` - `set dotenv-load := true` loads `.env` automatically - no separate tooling needed - `export PATH` to include `$(go env GOPATH)/bin` so Go-installed tools are always available - Prefer `just` over `make` for Go projects: no `.PHONY`, better variable handling, cross-platform, readable syntax ## Beyond the Basics Surface worth knowing about, none of it needed for the Justfile above: | Feature | What it does | |---------|--------------| | Agent skill | just ships its own: "A skill for agents is available in [skills/just] and may be installed manually or with `npx skills add casey/just --global`" | | `just-lsp` / `just-mcp` | An LSP server, and an MCP adapter - "just-mcp provides a model context protocol adapter to allow LLMs to query the contents of justfiles and run recipes" | | `[cache]` (1.54.0) | "Skip recipe invocations when a matching entry exists in the cache." Currently unstable | | `set lists` / `set guards` / `set lazy` | Unstable settings: list-valued variables, the `?` guard sigil, lazy evaluation | | Remote and markdown justfiles | Run recipes from a URL, or keep them in fenced code blocks inside a Markdown file | | Global / user justfiles | A personal recipe set available from any directory | | `--choose` / `--man` / `--dump` | Interactive recipe picker, a generated man page, and a machine-readable dump | | `[continue(SIGNALS)]` | "Continue execution normally if a command is interrupted by any of `SIGNALS` and exits successfully. Defaults to `SIGINT`" | | User-defined functions (1.47+) | Reusable expression-level helpers, distinct from recipes | | `[metadata]`, `[extension]`, `[no-cd]`, `[exit-message]`, `[default]` | Further recipe attributes | -
lefthook-reference.md 10.7 KB
# Lefthook Reference Latest: **v2.1.14** (2026-09-14). Single Go binary, no runtime dependency. `go install github.com/evilmartians/lefthook/v2@v2.1.14` needs Go 1.26+; Homebrew, npm, and the GitHub release binaries avoid that floor. Config is discovered at the repo root or in `.config/`, and read fresh on every hook run - "Reinstall is not required when you modify `lefthook.yml`, the configuration file is read every time a git hook is run." Only adding or removing a *hook section* requires `lefthook install`. ## Job Filtering (the part that silently skips work) **`glob` gates the job even when `run` has no file template.** This is the trap. The obvious reading is that `glob` only filters the list passed to `{staged_files}`, but the docs are explicit: > If you've specified `glob` but don't have a files template in `run` option, lefthook will check `{staged_files}` for `pre-commit` hook and `{push_files}` for `pre-push` hook and apply filtering. If no files left, the command will be skipped. So a whole-project job carrying a glob quietly does nothing on commits that touch no matching file: ```yaml pre-commit: commands: # WRONG when you want this to always run: skipped on a docs-only commit typecheck: glob: "*.go" run: go build ./... ``` **Rule of thumb: a job that checks the project carries no `glob`; a job that consumes `{staged_files}` keeps one.** For `go mod tidy` the glob is right - a commit touching no `.go`, `.mod`, or `.sum` file genuinely has nothing to tidy - but that should be a decision, not an accident. Two more filtering surprises: - **`**` matches one or more directories, not zero or more.** `glob: "src/**/*.go"` does *not* match `src/file.go`. Use separate patterns, or opt into standard semantics with `glob_matcher: doublestar`. - **Globs ignore `root`.** "Globs are still calculated from the actual root of the git repo." ## File Templates | Template | Contents | |----------|----------| | `{staged_files}` | Staged files (`pre-commit`) | | `{push_files}` | Files in the push range (`pre-push`) | | `{all_files}` | All tracked files | | `{files}` | Output of the job's custom `files` command | ## Monorepos: `root` `root` changes the working directory for the job - "You can change the CWD for the command you execute using `root` option." Without it, `go mod tidy` and `go tool` run at the repo root and fail against a module that lives deeper: ```yaml pre-commit: commands: mod-tidy: root: "services/api/" glob: "services/api/**/*.{go,mod,sum}" run: go mod tidy ``` ## Ordering and Failure Behaviour - **Sequential is the default.** "Lefthook runs commands and scripts **sequentially** by default." `parallel: true` opts into concurrency; `piped: true` is fail-fast - "Stop running commands and scripts if one of them fail." The two are mutually exclusive and lefthook errors if both are set. - **`priority`** orders jobs when `parallel: false` or `piped: true`. Values run low-to-high from 1; "Value `0` is considered an `+Infinity`", so unprioritised jobs run last. - **`commands:` is a map, and lefthook sorts it before running it.** Written order is not run order. The sort is `priority` first (0 last), then a leading numeric prefix in the name, then plain alphabetical comparison of the names (`internal/config/command.go:60-92`, same logic in `internal/config/script.go:54-85`). So a `piped: true` block of unprioritised commands runs alphabetically: `fmt` before `secrets`, `lint` before `test`. `jobs:` is a list and preserves declaration order - and its `Job` struct carries no `Priority` field at all, so `priority` is a `commands:`/`scripts:` option only. - **`fail_on_changes`** decides whether a job that modified tracked files fails: `never` (default), `always`, `ci` ("exit with a non-zero status only when the `CI` environment variable is set ... useful when combined with `stage_fixed` to ensure a frictionless devX locally, and a robust CI"), or `non-ci`. ## `stage_fixed` Re-stages files after a fixer rewrote them. Since v2.1.12 a failed re-stage fails the hook - "If the `git add` call fails, the hook fails too. Otherwise the commit would silently go through with the unfixed content." **It re-stages the substituted file list, not the files the command actually touched.** Lefthook stages the same list it handed the job - the filtered `{staged_files}` expansion, or the filtered staged set when the job used no file template (`internal/run/controller/job.go:155-181`). A file the command *created*, or fixed while absent from that list, is left unstaged and the commit goes through without the fix. **Unstaged work is hidden only for *partially staged* files.** The guard asks git for files dirty in *both* the index and the worktree, and if that list is empty it runs the hook with no stash at all (`internal/git/repo.go:228-246`, `internal/run/controller/guard.go:68-88`). A file carrying only unstaged changes - never `git add`ed - is not hidden, so the hook judges the on-disk file, not the indexed one. Verified live: an unstaged `Justfile` edit was the version the hook executed. Treat any claim that lefthook hides *all* unstaged changes for the hook's duration as wrong for 2.1.14. **Worktree hazard: the backup is shared across linked worktrees.** The partial-stage backup patch lands at `.git/info/lefthook-unstaged.patch` and the safety stash is stored under the message `lefthook auto backup` (`internal/git/repo.go:24-25`, `:170`). Both resolve through the *common* git dir - `git rev-parse --git-path info` and `refs/stash` are shared, not per worktree - so every linked worktree of a repo contends for one patch file and one stash entry, and two worktrees committing concurrently can destroy each other's unstaged changes. Open upstream, both unfixed in 2.1.14: [the shared backup patch and stash across linked worktrees](https://github.com/evilmartians/lefthook/issues/1529), and [a failed patch re-apply falling back to a bare `git checkout .`](https://github.com/evilmartians/lefthook/issues/1480), which discards unstaged changes in unrelated files too (`internal/git/repo.go:46`, `:302`). ## Guardrails ```yaml assert_lefthook_installed: true # bake an exit-1-if-missing check into the installed hook script min_version: 2.1.14 # refuse to run under an older lefthook ``` `assert_lefthook_installed` is the fix for the dormancy failure mode - "fail (with exit status 1) if `lefthook` executable can't be found in $PATH, under node_modules/, as a Ruby gem, or other supported method." **But it is not a runtime guard.** The flag is only a template argument, baked into the generated `.git/hooks/<hook>` script at `lefthook install` time (`internal/command/install.go:332`, `internal/templates/hook.tmpl:100-105`); nothing in `lefthook run` ever reads it. Flipping it in config changes nothing until you reinstall, and it does nothing at all for a CI job that invokes `lefthook run` directly. If you add a secret scanner, give it `priority: 1` so it runs before any formatter - otherwise a fixer can rewrite the file holding a credential before the scan ever reads it: ```yaml pre-commit: piped: true commands: secrets: priority: 1 run: your-secret-scanner {staged_files} # check your scanner's own staged-scan flags fmt: glob: "*.go" run: golangci-lint fmt {staged_files} stage_fixed: true ``` ## Sharing Config - **`extends:`** merges other local config files into this one. - **`remotes:`** pulls shared config from a git repo (`git_url`, `ref`, `configs`), with `refetch` and `refetch_frequency` controlling staleness. Useful for one lint policy across many services. - **`lefthook-local.yml`** is the gitignored per-developer override - "useful when you want to use lefthook locally without imposing it on your teammates." Named jobs merge across `extends` and local config; unnamed jobs append in definition order. ## CLI | Command | Purpose | |---------|---------| | `lefthook install` | Write the git hook shims; `install <hook>...` for specific hooks | | `lefthook uninstall` | Remove shims and restore any `.old` hooks | | `lefthook run <hook>` | Run a hook manually (this is what CI should call) | | `lefthook validate` | Check the config is well-formed | | `lefthook dump` | Print the merged effective config | | `lefthook add <hook>` | Scaffold a hook and its script directory | | `lefthook check-install` | Report whether hooks are installed | | `lefthook self-update` | Update the binary in place | | `lefthook version` | Print the version (`--full` includes the commit) | Two install behaviours worth knowing: - **A pre-existing foreign hook is preserved, not clobbered** - it is renamed to `.git/hooks/<hook>.old`, and `uninstall` restores it. This is what makes a pre-commit-to-lefthook migration safe. - **`install -f` does not prune hooks you deleted from config.** It syncs the hooks it knows about; a shim for a removed hook keeps firing until you delete it from `.git/hooks` by hand. ## Environment Variables | Variable | Effect | |----------|--------| | `LEFTHOOK=0` / `LEFTHOOK=false` | Disable lefthook entirely for this command | | `LEFTHOOK_EXCLUDE=job1,job2` | Skip named jobs | | `LEFTHOOK_OUTPUT` | Control which output sections print | | `LEFTHOOK_VERBOSE=1` | Verbose logging | | `LEFTHOOK_BIN` | Path to the lefthook binary to use | | `LEFTHOOK_CONFIG` | Path to the config file, overriding discovery | | `CI` | Recognised by `fail_on_changes: ci` and skip/only conditions | | `NO_COLOR` / `CLICOLOR_FORCE` | Disable / force colour | **`LEFTHOOK=0` fails silently open.** `lefthook run` checks the variable before anything else and returns success having done nothing (`internal/command/run.go:49`, which also accepts `false`), and the generated `.git/hooks/*` script exits 0 on the literal `0` before it even looks for the binary (`internal/templates/hook.tmpl:7-9`). A CI job that inherits `LEFTHOOK=0` from its environment goes green without running a single check, and nothing in the output says so. ## Agent Hooks (`ai:`, beta) Declares LLM agent hooks in the same config - "During `lefthook install`, lefthook generates the provider-specific settings file so that the agent calls `lefthook run <hook>` when the event fires." Providers and their generated files: `claude` (`.claude/settings.json`), `codex` (`.codex/hooks.json`), `cursor` (`.cursor/hooks.json`), `copilot` (`.github/hooks/lefthook.json`). ```yaml ai: claude: Stop: validate ``` Keys under a provider must be that provider's own event names. Claude, Codex, and Cursor keep user-authored entries in their settings files across install and uninstall; Copilot's file is rewritten wholesale. ## CI Run hooks in CI through `lefthook run`, and validate the config so a malformed file cannot silently disable every rule: ```yaml - run: lefthook validate - run: lefthook run pre-commit --all-files ```
-
-
CHANGELOG.md 10 KB
# Changelog All notable changes to this skill will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.4.1] - 2026-09-17 ### Changed - lefthook pins moved `v2.1.12` -> `v2.1.14` (released 2026-09-14) across `metadata.upstream`, the install command, and `min_version`. ### Fixed - **`stage_fixed` re-stages the substituted file list, not the files the command touched** - a file the command created, or fixed while absent from that list, is left unstaged and the commit goes through without the fix. - **The claim that lefthook hides all unstaged changes for a hook run was wrong.** Only *partially staged* files are hidden: if no file is dirty in both the index and the worktree, the hook runs with no stash, so a file carrying only unstaged edits is judged as it sits on disk. Verified live against 2.1.14. - **`assert_lefthook_installed` is not a runtime guard** - it is baked into the generated hook script at `lefthook install` time and never read by `lefthook run`, so flipping it changes nothing until reinstall and does nothing for CI that calls `lefthook run` directly. ### Added - `commands:` is a map that lefthook sorts before running (priority, then numeric name prefix, then alphabetical), so written order is not run order; `jobs:` is a list, preserves declaration order, and has no `priority` at all. - Worktree hazard: the partial-stage backup patch and the `lefthook auto backup` stash both resolve through the *common* git dir, so linked worktrees contend for one patch file and one stash entry and concurrent commits can destroy each other's unstaged changes. Two open upstream issues, both unfixed in 2.1.14. Verified against: lefthook@v2.1.14 ## [0.4.0] - 2026-09-09 ### Fixed - lefthook `lint` job ran `golangci-lint run --fix {staged_files}`, which fails on any commit spanning two directories (`named files must all be in one directory`, exit 7) and reports phantom `undefined:` typecheck errors when one file of a multi-file package is staged. Now derives the packages from the staged files; both shapes verified against lefthook 2.1.12 and golangci-lint 2.13.2. - The same hook fixed formatting with standalone `gofumpt -w` while the Justfile gated with `golangci-lint fmt` - the two-formatter mismatch the skill's own footgun warns about. - `gofumpt: extra-rules: true` is deprecated in golangci-lint and is not a neutral shorthand: it calls `Extra.Set("true")`, which also enables `balance_calls`, the rule gofumpt demoted as controversial and disabled by default. - `revive: enable-all-rules: true` made `fmt.Println` in a hello-world `main` a lint error. Under `enable-all-rules` a rule's `arguments` are silently ignored (the rule registers twice), so only `disabled: true` suppresses it; the template now does that and runs clean on a new project. - `piped: true` annotated as "run sequentially"; lefthook runs sequentially by default and `piped` means fail-fast. - `jobs:` attributed to lefthook v2 and described as superseding `commands:`; it landed in 1.10.0 and `commands:` is not deprecated. - justfile-reference gated formatting with `gofumpt -d` while its own `fmt` recipe fixed with `golangci-lint fmt`. - golangci-lint output formats described as "one of eight"; nine are listed. ### Changed - Stack pinned to Go 1.27.1, golangci-lint v2.13.2, gofumpt v0.12.0, golang-migrate v4.20.1, lefthook v2.1.12; govulncheck CI pin to v1.8.0. - **Breaking (upstream):** gofumpt v0.12.0 is based on Go 1.27's gofmt and requires Go 1.26 or later; it also changes import and blank-line layout in four cases, so upgrading reformats code. - golang-migrate: pin v4.20.1, not v4.20.0 - a release-workflow bug kept v4.20.0 off Docker and the package registries. - Quick Start installs golangci-lint as a binary instead of `go get -tool`; upstream warns the tools pattern "aren't guaranteed to work" and that tool dependencies can perturb the project's own graph. `-modfile` isolation documented as the fallback. - `toolchain go1.27.0` bumped to go1.27.1, per the skill's own govulncheck footgun. - Two-formatter footgun now cites a live instance: golangci-lint v2.13.2 vendors gofumpt v0.11.0 while a standalone install is v0.12.0. ### Added - `references/lefthook-reference.md`: job filtering (including `glob` skipping whole-project jobs), monorepo `root`, ordering and `fail_on_changes`, `stage_fixed` semantics, sharing config via `extends`/`remotes`, the CLI, env vars, and the beta `ai:` agent hooks. - `assert_lefthook_installed: true` - a one-line fix for the skill's own "lefthook is dormant until installed" footgun. - Footgun: the golangci-lint run lock is a single file in the system temp dir, not per-`GOLANGCI_LINT_CACHE`, so concurrent runs wait five seconds and then fail with `parallel golangci-lint is running`. Documented `run.allow-parallel-runners` / `allow-serial-runners` and the `[parallel]` recipe hazard. - Stale-cache footgun now names its costliest symptom - nolintlint reporting a load-bearing `//nolint` as unused - with `GL_DEBUG=nolint_filter` to prove which side is stale. - CI: `cache-dependency-path` for modules outside the repo root, setup-go's `post-if: success()` cache-save deadlock, and `golangci-lint config verify` as an explicit step. - Justfile traps: `go tool` resolves against the cwd module, version-manager shims as a fourth install path, and `GOFLAGS=-trimpath` to share one warm cache across worktrees. - golangci-lint: `run.allow-parallel-runners`/`allow-serial-runners`/`issues-exit-code`/`modules-download-mode`/`enable-build-vcs`, `output.path-mode`/`path-prefix`, Go plugins, mise install, `completion`, langserver, other CI systems. - just: its own agent skill, just-lsp/just-mcp, `[cache]`, `set dotenv-command` (incompatible with `dotenv-load`), `set default-script`, remote and markdown justfiles, and further attributes. - golang-migrate v4.20.0 fixes: S3 `ListObjects` pagination past 1000 migrations, lazy index build, `moby/moby` security swap; plus `GracefulStop`, custom loggers, and migration reversibility. - gotestsum: the `--raw-command` contract and running a pre-compiled test binary through `test2json`. - Go 1.27 surface: generic methods, `encoding/json/v2`, `go test -json` `OutputType`, and the four new `go fix` modernizers. - gofumpt: `-r` removal in favour of `gofmt -r`, govim editor integration. - mockery pin refreshed to v3.8.0. Verified against: go@1.27.1, golangci-lint@v2.13.2, gofumpt@v0.12.0, gotestsum@v1.13.0, golang-migrate@v4.20.1, just@1.58.0, lefthook@v2.1.12 ## [0.3.1] - 2026-09-09 ### Changed - Description condensed to fit the repo's 250-character limit. ## [0.3.0] - 2026-08-26 ### Fixed - CI lint job pinned golangci-lint `v2.11`, which predates Go 1.27 support and fails against `go-version: stable`. - `t.Context()` attributed to Go 1.21; it landed in Go 1.24. - `gotestsum tool matrix` does not exist; the subcommand is `tool ci-matrix`, so the CI partitioning snippet was broken as written. - `--jsonfile-timing-events` documented as a boolean; it takes a file path. - Go test attributes attributed to Go 1.24; they landed in Go 1.25. - Claim that `just` strictly enforces tab indentation; spaces work, and the skill's own templates use them. - golangci-lint-action `version` input documented as required; `action.yml` declares it optional. - Blanket "golang-migrate does not wrap migrations in transactions" corrected for Postgres multi-statement execution. - `go get -tool` tracked tools while every recipe called bare binaries, with no note that `go tool <name>` or `go install tool` bridges the two. ### Changed - Stack pinned to Go 1.27, golangci-lint v2.13.1, gofumpt v0.11.0, just 1.58.0, lefthook v2.1.11. - **Breaking (upstream):** gofumpt `-extra` takes a comma-separated rule list since v0.10.0, no longer a boolean. - GitHub Actions pins: `checkout` v6 to v7, `setup-go` v6 to v7, `upload-artifact` v4 to v7. - CI and Quick Start install pinned tool versions instead of `@latest`. - JSON Schema guidance now points at the versioned URL; the unversioned one tracks master and yields false positives. - Maximum linter preset uses `exhaustruct_v5` and `wsl_v5` instead of their deprecated names. ### Added - Footguns section: stale lint cache, config-file placement, formatter-gate mismatch, lefthook activation, version-floor mismatch. - govulncheck stdlib advisories track the go.mod `toolchain` line and red-light CI on commits touching no Go code. - Go 1.26/1.27 toolchain surface: `go fix` modernizers, default `stdversion` vet check, `go mod init` N-1 directive, `GOTOOLCHAIN` pinning. - golangci-lint: ~18 catalog linters, `swaggo` formatter, eight output formats including SARIF, incremental-adoption flags, `path-except`, `.golangci.reference.yml`, module plugins via `golangci-lint custom`. - gofumpt: redundant-parentheses default rule, `balance_calls` extra rule, `-e` flag, nested `extra.*` settings in golangci-lint. - Testing: `t.Chdir`, `t.Attr`/`t.Output`, `synctest.Sleep`, `httptest.NewTestServer`, `-artifacts`/`-outputdir`, `b.Loop` inlining fix. - Testing practice: `-count=1` defeats the test cache; goldens must not depend on `GOARCH`/`GOOS`. - golang-migrate: `create -format`/`-tz`, `x-migrations-table-quoted`, and the `Up()` vs `Steps(1)` distinction. - just: `[arg(...)]`, `[working-directory]`, `[env]`, `[parallel]`, `[script]`, `[timestamp]`, ten settings including `minimum-version`, `mod`, `just --fmt --check`, `--jobs`. - lefthook: `jobs:` supersedes the `commands:`/`scripts:` split; `lefthook validate`/`dump`; `lefthook-local` override. - Adjacent Tools table: `log/slog`, air/wgo, GoReleaser, sqlc. ### Deprecated - `exhaustruct` replaced by `exhaustruct_v5`; `gomodguard` by `gomodguard_v2`; `wsl` by `wsl_v5`. - mockgen reflect mode replaced by package mode; testcontainers `GenericContainer` replaced by `Run`. - gofumpt `Options.ExtraRules` replaced by `Options.Extra`. Verified against: go@1.27.0, golangci-lint@v2.13.1, gofumpt@v0.11.0, gotestsum@v1.13.0, golang-migrate@v4.19.1, just@1.58.0, lefthook@v2.1.11 -
LICENSE.txt 8.9 KB
Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS -
SKILL.md 24.2 KB
--- name: go-dev description: Opinionated Go setup with golangci-lint v2, gofumpt, gotestsum, golang-migrate, and just. Use when starting a Go project, configuring lint, format, test, coverage or CI, writing a Justfile, wiring migrations, or leaving a Makefile workflow. metadata: version: "0.4.1" categories: "development" topics: "go, golangci-lint, gofumpt, testing, just" upstream: "go@1.27.1, golangci-lint@v2.13.2, gofumpt@v0.12.0, gotestsum@v1.13.0, golang-migrate@v4.20.1, just@1.58.0, lefthook@v2.1.14" openclaw: homepage: https://github.com/tenequm/skills/tree/main/skills/go-dev emoji: "🐹" envVars: - name: DATABASE_URL required: false description: Connection string used by the Justfile migration recipes (golang-migrate) --- # Go Development Stack Opinionated, modern Go development setup. One tool per concern, zero overlap. ## When to Use - Starting a new Go project from scratch - Adding linting, formatting, or testing infrastructure - Setting up CI/CD for a Go service or library - Creating a Justfile to replace a Makefile - Adding database migration tooling - Migrating from scattered gofmt/govet/staticcheck invocations to a unified setup ## The Stack | Tool | Version | Role | Replaces | |------|---------|------|----------| | **Go** | 1.27+ | Language, toolchain, `go mod`, `go fix` | - | | **golangci-lint** | v2.13+ | Meta-linter (100+ linters + formatters + `fmt` command) | gofmt, govet, staticcheck, errcheck run separately | | **gofumpt** | v0.12+ | Strict formatter (superset of gofmt, 19 default rules) | gofmt | | **gotestsum** | v1.13+ | Test runner with readable output, watch mode, JUnit XML | Raw `go test` | | **just** | 1.58+ | Task runner | Makefile | | **golang-migrate** | v4.20+ | DB migrations (CLI + library + `embed.FS`) | Manual SQL scripts | | **lefthook** | v2.1+ | Git hooks (single binary, parallel) | pre-commit (Python) | **Version floors are load-bearing.** golangci-lint "supports Go versions lower or equal to the Go version used to compile it" - a pin older than your Go toolchain fails outright. Go 1.27 support landed in golangci-lint v2.13.0, so `v2.13` is the floor for a Go 1.27 project. Two more floors moved recently: gofumpt v0.12.0 "is based on Go 1.27's gofmt, and requires Go 1.26 or later", and lefthook's `go install` path now asks for Go 1.26+. ## Quick Start: New Project ```bash # 1. Create module mkdir myapp && cd myapp go mod init github.com/yourorg/myapp # 2. Scaffold directories mkdir -p cmd/myapp internal migrations # 3. Install golangci-lint as a binary, not as a module tool (see note below) curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.2 # 4. Track the rest in go.mod (Go 1.24+ tool directive). Pin versions - never @latest, # which recompiles the tool on every CI run and drifts between machines. go get -tool mvdan.cc/gofumpt@v0.12.0 go get -tool gotest.tools/gotestsum@v1.13.0 # golang-migrate needs a build tag, so install it directly go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.20.1 # 5. Create config files (templates below) # 6. Run: just check ``` **Do not install golangci-lint through the tools pattern.** Upstream is explicit: "Using `go install`/`go get`, \"tools pattern\", and `tool` command/directives installations aren't guaranteed to work. We recommend using binary installation." The reason that matters in a shared repo is dependency bleed - "the dependencies of a tool can modify the dependencies of another tool or your project". If you must have it in `go.mod`, isolate it behind its own `-modfile` - see the [golangci-lint Reference](references/golangci-lint-reference.md). **`go get -tool` tracks; `go tool` runs.** The tool directive records the dependency in `go.mod` but puts nothing on your PATH. Either invoke through the toolchain - `go tool gofumpt -l .`, `go tool gotestsum --format testname` - or `go install tool` once to populate `$(go env GOPATH)/bin`. The Justfile below calls the bare binaries, so it assumes the `go install tool` route (or a system install via Homebrew). Note that `go tool` resolves against the module in the current directory - "additional tools may be defined in the go.mod of the current module" - so in a monorepo it fails with `go: no such tool "..."` unless the recipe sets `[working-directory(...)]`. Two Go-command behaviours worth knowing before the first commit: - `go mod init` under a 1.N toolchain writes `go 1.(N-1).0`, not `1.N` - "Running `go mod init` using a toolchain of version `1.N.X` will create a `go.mod` file specifying the Go version `go 1.(N-1).0`." Bump the directive deliberately if you want 1.N language features. - Pin the toolchain for reproducibility with a `toolchain go1.27.1` line in `go.mod` (or `GOTOOLCHAIN=go1.27.1` in CI). Pin the current patch, not the `.0`: this line is what `govulncheck` compares stdlib advisories against, so a stale patch red-lights CI on its own - see Footguns below. ## .golangci.yml ```yaml version: "2" run: timeout: 5m linters: default: standard enable: - bodyclose - copyloopvar - dupl - durationcheck - err113 - errname - errorlint - exhaustive - exptostd - fatcontext - goconst - gocritic - gosec - intrange - misspell - modernize - musttag - nakedret - nestif - nilerr - noctx - nolintlint - nonamedreturns - perfsprint - prealloc - revive - sqlclosecheck - testifylint - thelper - unconvert - unparam - usestdlibvars - usetesting - wastedassign - whitespace - wrapcheck settings: govet: enable: - shadow gocritic: enabled-checks: - nestingReduce revive: enable-all-rules: true rules: # enable-all-rules turns on `unhandled-error`, which flags `fmt.Println` in main. # Under enable-all-rules a rule's `arguments` are ignored (the rule registers # twice), so an allowlist does not work here - only `disabled` takes effect. - name: unhandled-error disabled: true errcheck: check-type-assertions: true exclusions: generated: strict presets: - comments - std-error-handling - common-false-positives rules: - path: _test\.go linters: - gocyclo - errcheck - dupl - gosec - wrapcheck formatters: enable: - gofumpt - goimports settings: gofumpt: # Select rules individually. `extra-rules: true` is deprecated, and it also # switches on `balance_calls`, which gofumpt itself demoted as controversial. extra: group-params: true clothe-returns: true balance-calls: false exclusions: generated: strict paths: - vendor/ output: formats: text: path: stdout print-linter-name: true colors: true sort-order: - linter - file show-stats: true ``` ## Justfile ```just set shell := ["bash", "-euo", "pipefail", "-c"] set dotenv-load := true binary := "myapp" [private] default: @just --list --unsorted # ── Code Quality ────────────────────────────────────────── # Format all Go code [group('quality')] fmt: golangci-lint fmt ./... # Check formatting without modifying (CI-safe) [group('quality')] fmt-check: golangci-lint fmt --diff ./... # Run linter [group('quality')] lint: golangci-lint run ./... # Run linter with auto-fix [group('quality')] lint-fix: golangci-lint run --fix ./... # Run vulnerability check [group('quality')] vuln: govulncheck ./... # ── Testing ─────────────────────────────────────────────── # Run all tests with race detection [group('test')] test *args="./...": gotestsum --format testname -- -race {{ args }} # Run tests with coverage [group('test')] test-cov: gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./... go tool cover -func=coverage.out # Open coverage report in browser [group('test')] coverage: test-cov go tool cover -html=coverage.out # Run integration tests [group('test')] test-integration: gotestsum --format testname -- -race -tags=integration ./... # Watch tests during development [group('test')] test-watch: gotestsum --watch --watch-clear --format testname # Run benchmarks [group('test')] bench: go test -bench=. -benchmem ./... # ── Build ───────────────────────────────────────────────── # Build the binary [group('build')] build: go build -o {{ binary }} ./cmd/{{ binary }} # Build optimized release binary [group('build')] build-release: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }} # ── Dependencies ────────────────────────────────────────── # Tidy and verify modules [group('deps')] tidy: go mod tidy go mod verify # Run code generators [group('deps')] generate: go generate ./... # ── Database ────────────────────────────────────────────── # Apply all pending migrations [group('db')] migrate-up: migrate -path migrations -database "$DATABASE_URL" up # Revert last migration [group('db')] migrate-down: migrate -path migrations -database "$DATABASE_URL" down 1 # Create a new migration [group('db')] migrate-create name: migrate create -ext sql -dir migrations -seq {{ name }} # ── CI ──────────────────────────────────────────────────── # Full CI gate (format check + lint + test) [group('ci')] check: fmt-check lint test @echo "All checks passed" # Clean build artifacts [group('ci')] clean: go clean rm -f {{ binary }} coverage.out ``` ## Lefthook Config Lefthook is preferred over pre-commit for Go projects - it is a single Go binary, runs hooks in parallel, and needs no Python. ```bash go install github.com/evilmartians/lefthook/v2@v2.1.14 # needs Go 1.26+ lefthook install ``` ```yaml # lefthook.yml assert_lefthook_installed: true # fail loudly instead of skipping every rule pre-commit: piped: true # fail fast - stop at the first failing job commands: fmt: glob: "*.go" run: golangci-lint fmt {staged_files} stage_fixed: true lint: glob: "*.go" # Never pass a bare file list to `golangci-lint run`: a list spanning two # directories is rejected outright, and one file of a multi-file package # reports phantom `undefined:` typecheck errors. Lint the packages instead. run: printf '%s\n' {staged_files} | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix stage_fixed: true mod-tidy: glob: "*.{go,mod,sum}" run: go mod tidy pre-push: commands: test: run: go test -race ./... ``` `piped: true` is fail-fast, not ordering - lefthook "runs commands and scripts **sequentially** by default", and `piped` adds "Stop running commands and scripts if one of them fail." It cannot be combined with `parallel: true`. `jobs:` (added in lefthook 1.10.0) is the newer primitive alongside the `commands:`/`scripts:` split - "Jobs provide a flexible way to define tasks, supporting both commands and scripts. Jobs can be grouped for advanced flow control." `commands:` is not deprecated and stays fully documented; reach for `jobs:` when you need grouping, nested control flow, or a mix of inline commands and scripts in one hook. Four more worth wiring: - `assert_lefthook_installed: true`, above, is the antidote to the dormancy footgun below: "fail (with exit status 1) if `lefthook` executable can't be found in $PATH". - `lefthook validate` in CI catches a malformed `lefthook.yml` before it silently disables hooks; `lefthook dump` prints the merged effective config when a hook does not behave as written. - A gitignored `lefthook-local.yml` lets a developer add or skip jobs without imposing it on teammates - "This is useful when you want to use lefthook locally without imposing it on your teammates." - In a monorepo, give each job a `root:` pointing at its module directory; without it `go mod tidy` and `go tool` run against the repo root and fail. Beta, but worth knowing: `ai:` declares LLM agent hooks in the same file - "During `lefthook install`, lefthook generates the provider-specific settings file so that the agent calls `lefthook run <hook>` when the event fires", for `claude`, `codex`, `cursor`, and `copilot`. See the [Lefthook Reference](references/lefthook-reference.md) for the wider config surface. ## Project Structure ``` myapp/ cmd/ myapp/ main.go # Wire deps, call Run(), nothing else internal/ user/ # Domain logic, one package per domain user.go user_test.go repository.go transport/ # HTTP/gRPC handlers storage/ # Database layer migrations/ 000001_create_users.up.sql 000001_create_users.down.sql testdata/ # Test fixtures (ignored by go toolchain) .golangci.yml lefthook.yml Justfile go.mod go.sum Dockerfile ``` **Guidelines:** - `cmd/` - one directory per binary, keep `main.go` thin (~50 lines max) - `internal/` - all business logic goes here (compiler-enforced, cannot be imported externally) - `pkg/` - only add when another repo actually imports it today, not "maybe someday" - `testdata/` - test fixtures, golden files, fuzz corpus - `migrations/` - SQL migration files (timestamp or sequential versioned) ## Daily Workflow ```bash just fmt # Format code just lint # Run linter just test # Run tests with race detection just check # Full CI gate (fmt-check + lint + test) just test-watch # Watch mode during development just generate # Run go generate just tidy # go mod tidy + verify ``` `go fix` is the toolchain-native complement to the `modernize` linter: Go 1.26 rebuilt it as a codebase modernizer - "The venerable `go fix` command has been completely revamped and is now the home of Go's *modernizers*. It provides a dependable, push-button way to update Go code bases to the latest idioms and core library APIs." Run `go fix ./...` after a toolchain bump, before the linter has to complain. Go 1.27 added four more modernizers - "The go fix command contains several new modernizers (atomictypes, embedlit, slicesbackward, and unsafefuncs)" - and removed `fmtappendf`, so a 1.27 bump is a good moment to run it. Three other Go 1.27 changes touch this stack directly: - **Generic methods.** "Go 1.27 now supports generic methods: a method declaration may declare its own type parameters." - **`encoding/json/v2`.** "The encoding/json package is now backed by the v2 implementation" - behaviour-compatible by default, but worth knowing before you debug a marshalling difference. - **`go test -json` gained an `OutputType` field**, annotating `"Action":"output"` lines. This is the stream gotestsum consumes, so it lands in your test tooling whether or not you use it directly. ## Footguns Seven failure modes that cost real debugging time, none of which produce an obvious error message. **Config placement is load-bearing.** `.golangci.yml` must sit at the repo root: golangci-lint searches the working dir and its parents, and editor Go plugins auto-detect only a root `.golangci.*`, so filing it under `.github/` costs in-IDE linting even if you pass `--config`. lefthook auto-discovers only the repo root or `.config/` - move `lefthook.yml` anywhere else and commits silently stop running hooks, because git invokes the hook directly and no task-runner recipe can intercept that. **lefthook is dormant until installed.** The binary being absent from PATH, or `lefthook install` never having run, both present as "hooks just don't fire" with no warning. Set `assert_lefthook_installed: true` so this fails loudly, pin lefthook as a repo tool, and make `lefthook install` part of onboarding. **A stale lint cache invents issues.** golangci-lint can report failures in files that no longer exist on disk - typically after a branch switch or a deleted worktree. The costlier variant is nolintlint reporting a load-bearing `//nolint` directive as unused, which tempts you to delete a real suppression. Prove which side is lying with `GL_DEBUG=nolint_filter` before touching the code, and run `golangci-lint cache clean` if issue counts look impossible. When several worktrees share a checkout, give each its own cache with `GOLANGCI_LINT_CACHE=<worktree>/.golangci-cache` - and note the cache does not reliably invalidate on config, tool, or dependency changes, so fold those into the cache key if a phantom keeps returning. **Concurrent golangci-lint runs fail rather than queue.** The lock is a single file in the system temp dir, *not* per-`GOLANGCI_LINT_CACHE`, so per-worktree cache isolation does not prevent it. A second run waits five seconds, then exits with `parallel golangci-lint is running`. This bites hardest in a `just` recipe with `[parallel]` that runs `fmt` and `run` together, on green code. Set `run.allow-serial-runners: true` to wait indefinitely instead of failing, or `run.allow-parallel-runners: true` to drop the lock entirely. **Don't run two formatters against one gate.** Standalone `gofumpt -w` and `golangci-lint fmt` do not always agree on the same file, so a repo that fixes with one and gates with the other fails CI on code it just formatted. This is currently live rather than theoretical: golangci-lint v2.13.2 vendors gofumpt v0.11.0, while a standalone install is v0.12.0, and v0.12.0 changed how imports carrying comments and blank lines are laid out. Pick one as both fixer and gate - the Justfile and the hook above both use `golangci-lint fmt`. **A pinned linter older than your Go toolchain fails outright.** This is the same trap as the version floor above, and it usually surfaces first as a config-schema rejection: a config authored against a newer golangci-lint hits `additional properties ... not allowed` under the pinned CI version. Bump the CI pin and the local install together. **`govulncheck` fails on stdlib advisories, not just your code.** Advisories are matched against the `toolchain` line in `go.mod`, so a lagging toolchain red-lights CI on commits that touch zero Go code - and a failed test-and-lint job typically skips the release job downstream. When `govulncheck` reports vulnerabilities "in the Go standard library" all marked fixed in a patch you don't have, the fix is bumping the toolchain, not editing code. ## CI/CD Pipeline (GitHub Actions) ```yaml name: Go CI on: push: branches: [main] pull_request: permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: stable - uses: golangci/golangci-lint-action@v9 with: version: v2.13 - name: Verify lint config against the pinned binary run: golangci-lint config verify test: runs-on: ubuntu-latest needs: lint strategy: matrix: go-version: [stable, oldstable] steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: ${{ matrix.go-version }} - run: go install gotest.tools/gotestsum@v1.13.0 - name: Test run: gotestsum --format github-actions --junitfile unit-tests.xml -- -race -coverprofile=coverage.out -covermode=atomic ./... - uses: actions/upload-artifact@v7 if: always() with: name: test-results-${{ matrix.go-version }} path: unit-tests.xml security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: stable - run: go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - run: govulncheck ./... ``` Two `setup-go` behaviours decide whether this workflow is fast or pathologically slow: - **It hashes a repo-root `go.mod`.** Caching is on by default, but a module in a subdirectory never matches, so every run logs a restore failure and cold-compiles the whole dependency tree. Point `cache-dependency-path` at the real file. - **The cache is saved in a post step declared `post-if: success()`.** A job that fails saves nothing, so a cold run that times out stays cold forever and raising the timeout never breaks the loop. Split lint and test into separate jobs so one slow gate cannot starve the other's cache. `golangci-lint config verify` earns its place as an explicit step: a config authored against a newer binary is accepted locally and rejected by the pinned CI version, and without this step that surfaces as a confusing lint failure much later in the job. ## Existing Project Migration ```bash # 1. Install tools (golangci-lint as a binary - see Quick Start) curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.13.2 go install mvdan.cc/gofumpt@v0.12.0 go install gotest.tools/gotestsum@v1.13.0 # 2. Migrate existing golangci-lint v1 config golangci-lint migrate # 3. Format codebase gofumpt -w . # 4. Run linter (fix what you can, nolint the rest) golangci-lint run --fix ./... # 5. Replace go test with gotestsum in scripts/CI # Before: go test -v ./... # After: gotestsum --format testname -- -race ./... # 6. Copy Justfile and lefthook.yml templates above # 7. Run: just check ``` For incremental adoption on large codebases, use `only-new-issues: true` in the GitHub Action to only lint changed code. Outside the Action, `--new-from-merge-base=main` and `--new-from-rev=<rev>` do the same locally - see the [golangci-lint Reference](references/golangci-lint-reference.md) for the full set. Expect new findings after a toolchain bump: since Go 1.27, "`go test` now invokes the `stdversion` vet check by default. This reports the use of standard library symbols that are too new for the Go version in force in the referring file". Adjust the `go` directive or the call site rather than suppressing it. ## Adjacent Tools Not part of the core stack, but the gaps most projects fill next: | Need | Tool | Why | |------|------|-----| | Structured logging | `log/slog` (stdlib) | The default since Go 1.21; the `sloglint` linter enforces a consistent call style | | Hot reload for a running service | [air](https://github.com/air-verse/air) or [wgo](https://github.com/bokwoon95/wgo) | `just test-watch` covers tests; neither `go run` nor gotestsum restarts a server on save | | Release binaries + changelog | [GoReleaser](https://goreleaser.com/) | Cross-compile, checksum, sign, and publish from one config | | Type-safe SQL from schema | [sqlc](https://sqlc.dev/) | Generates Go from the same SQL your migrations define, so `storage/` stays hand-written-free | ## Reference Docs - [golangci-lint Reference](references/golangci-lint-reference.md) - v2 config, linter catalog, recommended sets, nolint syntax - [gofumpt Reference](references/gofumpt-reference.md) - formatting rules, editor integration, golangci-lint integration - [gotestsum Reference](references/gotestsum-reference.md) - output formats, watch mode, JUnit XML, CI recipes - [Go Testing Reference](references/go-testing-reference.md) - table-driven tests, mocking, benchmarks, coverage, fuzz testing - [golang-migrate Reference](references/go-migrate-reference.md) - CLI, library, embed.FS, transactions, pitfalls - [Justfile Reference](references/justfile-reference.md) - Go-specific recipes, task groups, lefthook integration - [Lefthook Reference](references/lefthook-reference.md) - job filtering, monorepo roots, remote configs, CLI, env vars ## Resources - [Go Official Docs](https://go.dev/doc/) - [golangci-lint Docs](https://golangci-lint.run/) - [gofumpt](https://github.com/mvdan/gofumpt) - [gotestsum](https://github.com/gotestyourself/gotestsum) - [golang-migrate](https://github.com/golang-migrate/migrate) - [Lefthook](https://github.com/evilmartians/lefthook) - [just](https://github.com/casey/just) - [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) - [Go 1.27 Release Notes](https://go.dev/doc/go1.27) - [Go Release History](https://go.dev/doc/devel/release)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.