aboutsummaryrefslogtreecommitdiff
path: root/languages
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-02 18:22:11 -0500
committerCraig Jennings <c@cjennings.net>2026-06-02 18:22:11 -0500
commit3a06aff7eec20814f6b51b72691f4140668189c2 (patch)
tree0dcfde239685ebeabea3ce941317f1dac5be8349 /languages
parent0b07c15fb33ceaeec484dec9889c37098ec2e844 (diff)
downloadrulesets-3a06aff7eec20814f6b51b72691f4140668189c2.tar.gz
rulesets-3a06aff7eec20814f6b51b72691f4140668189c2.zip
feat(go): build out the full Go language bundle
The Go bundle was coverage-slice-only. Because it shipped no rule files, sync-language-bundle.sh (which fingerprints a project's bundle by spotting one of its rule files in .claude/rules/) couldn't detect it, so the coverage slice it did ship never stayed in sync. Adding the rules is what makes the bundle sync-maintainable, which was the point. Brought Go to the full tier, matching elisp: - claude/rules/go.md and go-testing.md, the style and testing rules (table-driven tests, go test -race, errors.Is over message matching, how the coverage slice fits). These two are also the sync fingerprint. - claude/hooks/validate-go.sh, a PostToolUse hook that runs gofmt and go vet on each edited .go file. go vet type-checks, so compile and syntax errors surface at edit time. It deliberately doesn't auto-run tests, since a package's tests can be slow or integration-tagged and shouldn't fire on every keystroke. - claude/settings.json, Go permissions plus the hook wiring. - githooks/pre-commit, a secret scan and a gofmt check on staged .go. - CLAUDE.md, the seed. validate-go.sh is TDD'd by scripts/tests/validate-go.bats: a clean file passes, gofmt and vet failures both block with the JSON payload, and non-go, missing, or empty paths are ignored. I updated install-lang.bats test 7, which asserted Go installs no CLAUDE.md, to check the full bundle instead. Verified with a real install into a throwaway project and a green make test.
Diffstat (limited to 'languages')
-rw-r--r--languages/go/CLAUDE.md75
-rwxr-xr-xlanguages/go/claude/hooks/validate-go.sh62
-rw-r--r--languages/go/claude/rules/go-testing.md166
-rw-r--r--languages/go/claude/rules/go.md82
-rw-r--r--languages/go/claude/settings.json85
-rwxr-xr-xlanguages/go/githooks/pre-commit51
6 files changed, 521 insertions, 0 deletions
diff --git a/languages/go/CLAUDE.md b/languages/go/CLAUDE.md
new file mode 100644
index 0000000..b50a05a
--- /dev/null
+++ b/languages/go/CLAUDE.md
@@ -0,0 +1,75 @@
+# CLAUDE.md
+
+## Project
+
+Go project. Customize this section with your own description, layout, and conventions.
+
+**Typical layout:**
+- `main.go` or `cmd/<name>/main.go` — entry points
+- `internal/` — packages private to this module
+- `pkg/` — packages intended for external import (only if you publish them)
+- `<package>/<file>_test.go` — tests beside the code they exercise
+- `testdata/` — fixtures (ignored by the build tool)
+
+## Build & Test Commands
+
+If the project has a Makefile, document targets here. Common pattern:
+
+```bash
+make test # go test ./...
+make test -- -run Pattern # match test names
+make coverage # suite under -coverprofile + go tool cover -func
+make coverage-summary # file-weighted total + files absent from the profile
+make lint # gofmt check + go vet (+ staticcheck/golangci-lint if used)
+make build # go build ./...
+```
+
+Direct equivalents: `go test ./...`, `go test -race ./...`, `go vet ./...`,
+`go build ./...`, `gofmt -l .`.
+
+## Language Rules
+
+See rule files in `.claude/rules/`:
+- `go.md` — code style and patterns
+- `go-testing.md` — testing conventions (table-driven, `-race`, coverage)
+- `verification.md` — verify-before-claim-done discipline
+
+## Git Workflow
+
+Commit conventions: see `.claude/rules/commits.md` (author identity,
+no AI attribution, message format).
+
+Pre-commit hook in `githooks/` scans for secrets and runs `gofmt -l` on staged
+`.go` files. Activate on a fresh clone with `git config core.hooksPath githooks`.
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant package and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing test (a table row) that captures the correct behavior
+4. Fix, then re-run tests with `go test -race ./...`
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you
+can't write the test, you don't yet understand the change. Table-driven tests
+express the Normal/Boundary/Error categories; details in
+`.claude/rules/go-testing.md`.
+
+## Editing Discipline
+
+A PostToolUse hook runs `gofmt` and `go vet` on every `.go` file after
+Edit/Write/MultiEdit. `go vet` type-checks, so compile errors and suspicious
+constructs surface at edit time — read them. Tests aren't auto-run on edit
+(a package's tests can be slow or integration-tagged); run them with `make
+coverage` or `go test`.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't ignore a returned error to shorten a line
+- Don't add comments to code you didn't change
+- Don't create an interface or abstraction for a single implementation
+- Don't commit credentials or API keys — the pre-commit hook catches common patterns but isn't a substitute for care
diff --git a/languages/go/claude/hooks/validate-go.sh b/languages/go/claude/hooks/validate-go.sh
new file mode 100755
index 0000000..c2c6ff1
--- /dev/null
+++ b/languages/go/claude/hooks/validate-go.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Validate Go files after Edit/Write/MultiEdit.
+# PostToolUse hook: receives tool-call JSON on stdin.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
+#
+# Phase 1: gofmt — formatting must be clean.
+# Phase 2: go vet — compile + suspicious-construct check on the file's package,
+# run only when the file sits inside a module (go.mod found). go vet
+# type-checks, so it surfaces compile and syntax errors too.
+#
+# Tests deliberately stop here: `go test` on the edited package can pull in slow
+# or integration-tagged tests that shouldn't fire on every keystroke. Run them
+# explicitly via `make coverage` / `go test`.
+
+set -u
+
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "GO VET FAILED")
+# $2 — file path
+# $3 — tool output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ "${f##*.}" = "go" ] || exit 0
+[ -f "$f" ] || exit 0
+
+# No toolchain on this machine — nothing to validate, don't block the edit.
+command -v gofmt >/dev/null 2>&1 || exit 0
+
+# --- Phase 1: formatting ---
+# gofmt -l prints the path when the file isn't gofmt-clean (and stays silent on
+# a parse error — go vet catches those in Phase 2). Show the diff so the fix is
+# obvious.
+if [ -n "$(gofmt -l "$f" 2>/dev/null)" ]; then
+ fail_json "GOFMT: file is not gofmt-clean" "$f" "$(gofmt -d "$f" 2>&1)"
+fi
+
+# --- Phase 2: vet (needs module context) ---
+command -v go >/dev/null 2>&1 || exit 0
+dir="$(dirname "$f")"
+gomod="$(cd "$dir" && go env GOMOD 2>/dev/null)"
+if [ -n "$gomod" ] && [ "$gomod" != "/dev/null" ]; then
+ if ! out="$(cd "$dir" && go vet . 2>&1)"; then
+ fail_json "GO VET FAILED" "$f" "$out"
+ fi
+fi
+
+exit 0
diff --git a/languages/go/claude/rules/go-testing.md b/languages/go/claude/rules/go-testing.md
new file mode 100644
index 0000000..3b20efc
--- /dev/null
+++ b/languages/go/claude/rules/go-testing.md
@@ -0,0 +1,166 @@
+# Go Testing Rules
+
+Applies to: `**/*_test.go`
+
+Implements the core principles from `testing.md`. All rules there apply here —
+this file covers Go-specific patterns.
+
+## Framework: the standard `testing` package
+
+Use the standard library `testing` package. Reach for a third-party assertion
+library (`testify`) only when a project already uses it; don't introduce it for
+new code. Plain `if got != want { t.Errorf(...) }` is the idiom, and it keeps
+the failure message under your control.
+
+Avoid full BDD frameworks (Ginkgo/Gomega) unless the project standardizes on
+them — they obscure the standard `go test` failure output.
+
+## Table-Driven Tests Are the Default
+
+A table-driven test is how Go expresses the Normal / Boundary / Error
+categories from `testing.md` in one place. Each row is a case; `t.Run` gives
+each a named subtest so a failure points at the exact row.
+
+```go
+func TestCartApplyDiscount(t *testing.T) {
+ tests := []struct {
+ name string
+ coupon string
+ want int
+ wantErr bool
+ }{
+ {"normal percentage off", "SAVE10", 90, false}, // Normal
+ {"zero discount is a no-op", "SAVE0", 100, false}, // Boundary
+ {"expired coupon rejected", "EXPIRED", 0, true}, // Error
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ApplyDiscount(100, tt.coupon)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("ApplyDiscount() err = %v, wantErr = %v", err, tt.wantErr)
+ }
+ if !tt.wantErr && got != tt.want {
+ t.Errorf("ApplyDiscount() = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+```
+
+Use `t.Errorf` to record a failure and keep going (multiple assertions per
+case); use `t.Fatalf` when continuing would panic or test nothing useful (a
+`nil` you're about to dereference).
+
+### Pairwise for Parameter-Heavy Functions
+
+When the table would need dozens of rows to cover the combinations of three or
+more parameters (feature flags × roles × shipping × payment), switch to
+combinatorial coverage via `/pairwise-tests`. It generates a minimal matrix
+hitting every 2-way interaction — usually 80-99% fewer rows than exhaustive —
+which you paste straight back into the table. See `testing.md` § Combinatorial
+Coverage for when to skip.
+
+## Run With the Race Detector
+
+Run the suite with `-race` for anything that touches goroutines, channels, or
+shared state. The race detector catches data races that are invisible in a
+plain run and flaky in production.
+
+```
+go test -race ./...
+```
+
+Wire `-race` into the project's `make test`. A passing race-free run is the bar
+for concurrent code, not an optional extra.
+
+## Test Naming and Location
+
+- Tests live in `<file>_test.go` beside the code, in the same package
+ (`package foo`) for white-box tests, or `package foo_test` for black-box
+ tests that exercise only the exported API. Prefer black-box (`foo_test`) when
+ you're testing the contract; it keeps tests honest about what's public.
+- Top-level test functions: `TestThing`, scenario carried by the subtest name
+ via `t.Run("expired coupon rejected", ...)`.
+- Use `testing.T.Parallel()` on independent subtests to surface ordering
+ assumptions and speed the suite. Capture the range variable first
+ (`tt := tt`) on Go versions before 1.22.
+
+## Error Behavior, Not Error Text
+
+Assert the error's identity, not its prose. Production messages get reworded;
+behavior doesn't.
+
+```go
+got := Withdraw(account, 999)
+if !errors.Is(got, ErrInsufficientFunds) {
+ t.Errorf("Withdraw() error = %v, want ErrInsufficientFunds", got)
+}
+```
+
+Use `errors.Is` for sentinel errors and `errors.As` for typed errors. Match on
+a substring only when a specific value must appear (the offending filename),
+never on the whole message. This mirrors `testing.md`'s error-behavior rule.
+
+## Fixtures: `testdata/` and Golden Files
+
+- Put fixture inputs in a `testdata/` directory — the Go tool ignores it for
+ builds, and it travels with the package.
+- For large expected outputs, use the golden-file pattern: compare against
+ `testdata/<name>.golden` and regenerate with a `-update` flag.
+
+```go
+var update = flag.Bool("update", false, "update golden files")
+// ... if *update { os.WriteFile(golden, got, 0o644) }
+```
+
+- Use `t.TempDir()` for scratch directories and `t.Cleanup()` for teardown;
+ both are removed automatically and survive `t.Parallel()`.
+
+## Mocking at Boundaries — Via Interfaces
+
+Go's interfaces are the seam. Define a small interface the code under test
+depends on, and pass a fake in the test. Don't mock concrete types you own.
+
+### Mock these (external boundaries):
+- Network: use `net/http/httptest.Server` for HTTP clients, not a mocked
+ `http.Client` transport you hand-build.
+- Time: inject a `func() time.Time` clock rather than calling `time.Now()`
+ directly in business logic.
+- Filesystem: accept an `fs.FS` (or `io.Reader`/`io.Writer`) so a test can pass
+ `fstest.MapFS` or a buffer.
+- Third-party service clients: depend on a narrow interface the package
+ defines, not the vendor's concrete client.
+
+### Never mock these (internal domain):
+- Concrete structs and methods you own — call them directly with real inputs.
+- Pure functions (parsing, encoding, calculation) — those are the work.
+- The standard library's own behavior.
+
+If a test needs an elaborate fake to stand in for your own code, that's a
+design signal: extract a smaller interface or split the function (see
+`testing.md` § If Tests Are Hard to Write).
+
+## Measuring Coverage — `make coverage-summary`
+
+The bundle ships a coverage summary at `.claude/scripts/coverage-summary.go`
+plus a Makefile fragment (`coverage-makefile.txt`) with `coverage` and
+`coverage-summary` targets. After `make coverage` runs the suite with
+`-coverprofile` and prints `go tool cover -func`'s per-function table, `make
+coverage-summary` prints a file-weighted project number and every source file
+absent from the profile.
+
+The number to watch is that missing-file count. `go test ./...` lists in-module
+packages in the profile (at 0% when untested), so the missing list is usually
+empty for in-module code — it earns its keep on build-tagged files and dirs
+outside the `./...` compilation. The summary weights by file and counts an
+absent file as 0%, so untested code stays visible instead of being averaged
+away. It doesn't reimplement the per-function table — `go tool cover -func`
+already prints that. Copy the fragment's targets into your own Makefile to
+adopt it; the bundle never edits your Makefile.
+
+## What the Validate Hook Already Enforces
+
+A PostToolUse hook runs `gofmt` and `go vet` on every edited `.go` file (see
+the bundle's `CLAUDE.md`). Formatting and compile errors are caught at edit
+time, so tests don't need to re-assert them — write tests for behavior, not for
+"it compiles."
diff --git a/languages/go/claude/rules/go.md b/languages/go/claude/rules/go.md
new file mode 100644
index 0000000..5415a6a
--- /dev/null
+++ b/languages/go/claude/rules/go.md
@@ -0,0 +1,82 @@
+# Go Code Rules
+
+Applies to: `**/*.go`
+
+Go-specific style and structure. Pairs with `go-testing.md` for tests and the
+generic `verification.md` / `commits.md` rules. When in doubt, defer to
+[Effective Go](https://go.dev/doc/effective_go) and the
+[Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) wiki.
+
+## Formatting Is Not a Choice
+
+All code is `gofmt`-clean. The bundle's PostToolUse hook runs `gofmt` and `go
+vet` on every edit and blocks on a violation, and the pre-commit hook re-checks
+staged files. Don't hand-format; let the tool decide. Use `goimports` (or your
+editor's organize-imports) to keep the import block grouped and pruned.
+
+## Errors
+
+- Return errors; don't panic across an API boundary. `panic` is for genuinely
+ unrecoverable, programmer-error states (a corrupt invariant), not for control
+ flow or expected failures.
+- Wrap with context as an error travels up: `fmt.Errorf("load config %s: %w",
+ path, err)`. The `%w` verb preserves the chain for `errors.Is` / `errors.As`.
+- Handle every error where it occurs. Don't assign to `_` to silence one unless
+ you can state why it's safe in a comment.
+- Define sentinel errors (`var ErrNotFound = errors.New("not found")`) or typed
+ errors when callers need to branch on the failure; callers match with
+ `errors.Is` / `errors.As`, never on the message string.
+- Check errors before using the other return value — the value is unspecified
+ when `err != nil`.
+
+## Naming and Shape
+
+- Package names are short, lowercase, no underscores or camelCase; the name is
+ part of every call site (`http.Client`, not `httputil.HTTPClient`). Avoid
+ stutter: `chat.Message`, not `chat.ChatMessage`.
+- Exported identifiers carry a doc comment that begins with the identifier
+ name. Unexported helpers earn a comment when the why isn't obvious.
+- Accept interfaces, return concrete types. Keep interfaces small — one or two
+ methods — and define them in the consuming package, not alongside the
+ implementation.
+- Avoid naked returns except in very short functions; named returns that travel
+ more than a few lines hide what's being returned.
+
+## Concurrency
+
+- Pass `context.Context` as the first parameter (`ctx context.Context`) through
+ any call chain that does I/O or can block; honor cancellation. Never store a
+ context in a struct.
+- A goroutine needs a defined lifecycle — know who stops it and how. Don't
+ launch one without a way for it to exit (a closed channel, a cancelled
+ context, a `sync.WaitGroup`).
+- Share memory by communicating (channels) or guard it (`sync.Mutex`); don't do
+ both for the same state. Run concurrent code under `go test -race`.
+
+## Structure
+
+- Keep functions focused. A function that fetches, parses, decodes, and saves
+ is four functions; the test difficulty in `go-testing.md` is the tell.
+- Zero values should be useful where you can manage it (`bytes.Buffer`,
+ `sync.Mutex` need no constructor). When construction must validate, provide a
+ `NewThing` that returns `(*Thing, error)`.
+- Don't add an interface, generic, or abstraction for a single implementation.
+ Add it when the second caller arrives, not in anticipation.
+- `defer` cleanup (`f.Close()`, `mu.Unlock()`) right after acquiring the
+ resource, so the release is visible next to the acquire.
+
+## Dependencies
+
+- Keep `go.mod` tidy: run `go mod tidy` after changing imports, commit the
+ resulting `go.mod` and `go.sum` together.
+- Prefer the standard library. Pull in a dependency when it earns its
+ maintenance and supply-chain cost, not for a function you could write in a
+ few lines.
+
+## What Not to Do
+
+- Don't ignore a returned error to make the line shorter.
+- Don't reach for `interface{}` / `any` when a concrete type or a small
+ generic constraint says what you mean.
+- Don't refactor surrounding code while fixing a bug — keep the diff scoped.
+- Don't add comments restating what the code already says; comment the why.
diff --git a/languages/go/claude/settings.json b/languages/go/claude/settings.json
new file mode 100644
index 0000000..1bd333a
--- /dev/null
+++ b/languages/go/claude/settings.json
@@ -0,0 +1,85 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make coverage)",
+ "Bash(make coverage-summary)",
+ "Bash(make lint)",
+ "Bash(make build)",
+ "Bash(go build)",
+ "Bash(go build *)",
+ "Bash(go test)",
+ "Bash(go test *)",
+ "Bash(go vet)",
+ "Bash(go vet *)",
+ "Bash(go run *)",
+ "Bash(go list *)",
+ "Bash(go env)",
+ "Bash(go env *)",
+ "Bash(go version)",
+ "Bash(go mod tidy)",
+ "Bash(go mod verify)",
+ "Bash(go mod download)",
+ "Bash(go tool cover *)",
+ "Bash(gofmt *)",
+ "Bash(gofumpt *)",
+ "Bash(staticcheck *)",
+ "Bash(golangci-lint run *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-go.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/go/githooks/pre-commit b/languages/go/githooks/pre-commit
new file mode 100755
index 0000000..a3d6f3f
--- /dev/null
+++ b/languages/go/githooks/pre-commit
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Pre-commit hook: secret scan + gofmt check on staged .go files.
+# Use `git commit --no-verify` to bypass for confirmed false positives.
+
+set -u
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+# --- 1. Secret scan ---
+# Patterns for common credentials. Scans only added lines in the staged diff.
+SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])'
+
+secret_hits="$(git diff --cached -U0 --diff-filter=AM \
+ | grep '^+' | grep -v '^+++' \
+ | grep -iEn "$SECRET_PATTERNS" || true)"
+
+if [ -n "$secret_hits" ]; then
+ echo "pre-commit: potential secret in staged changes:" >&2
+ echo "$secret_hits" >&2
+ echo "" >&2
+ echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2
+ echo "bypass with: git commit --no-verify" >&2
+ exit 1
+fi
+
+# --- 2. gofmt check on staged .go files ---
+# gofmt -l lists files that aren't gofmt-clean. Skip generated and vendored
+# files the same way the rest of the toolchain does.
+staged_go="$(git diff --cached --name-only --diff-filter=AM \
+ | grep '\.go$' \
+ | grep -vE '(^|/)vendor/' || true)"
+
+if [ -n "$staged_go" ] && command -v gofmt >/dev/null 2>&1; then
+ unformatted=""
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ if [ -n "$(gofmt -l "$f" 2>/dev/null)" ]; then
+ unformatted="${unformatted}${f}"$'\n'
+ fi
+ done <<< "$staged_go"
+
+ if [ -n "$unformatted" ]; then
+ printf 'pre-commit: gofmt check failed — these files need `gofmt -w`:\n\n%s\n' "$unformatted" >&2
+ echo "Run: gofmt -w <file> (or your editor's format-on-save), then re-stage." >&2
+ exit 1
+ fi
+fi
+
+exit 0