Claude Skill

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

LLM Mart · 0 points · 17 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download tenequm-skills-skills_go-dev-1cf72df.zip · 55 KB
Part of tenequm/skills — 25 skills

Install

skills CLI npx skills add https://github.com/tenequm/skills/tree/main/skills/go-dev
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
Git 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 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

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) 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 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

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)

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

# 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

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.

No comments yet.

Reviews (0)

No reviews yet.

Related