init: go/template v0.1.1

This commit is contained in:
djmil 2026-03-05 21:52:10 +01:00
commit 8e7100a427
25 changed files with 1721 additions and 0 deletions

View File

@ -0,0 +1,64 @@
{
"name": "Template",
"image": "golang:1.25-bookworm",
"features": {
// Creates a non-root 'vscode' user with sudo standard devcontainer UX
"ghcr.io/devcontainers/features/common-utils:2": {
"username": "vscode"
}
},
"remoteUser": "vscode",
// Run once after the container is created.
"postCreateCommand": "make init",
// Fix ownership of the mounted ~/.claude so the vscode user can read host auth.
"postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude 2>/dev/null || true",
"customizations": {
"vscode": {
"extensions": [
"golang.go", // official Go extension (gopls)
"EditorConfig.EditorConfig", // respect .editorconfig
"streetsidesoftware.code-spell-checker", // spelling (matches misspell linter)
"mhutchie.git-graph", // lightweight git history graph
"anthropic.claude-code" // Claude Code agent
],
"settings": {
// Disable all Copilot features
"github.copilot.enable": { "*": false },
"github.copilot.editor.enableAutoCompletions": false,
"github.copilot.chat.enabled": false,
"github.copilot.nextEditSuggestions.enabled": false,
"github.copilot.inlineSuggest.enable": false,
"go.useLanguageServer": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"go.lintOnSave": "workspace",
"go.testFlags": ["-race"],
"go.generateOnSave": false,
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
}
}
},
// Reuse host Claude config (auth, settings) no re-login needed inside container.
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached"
],
// Forward the default HTTP port so `make run` is reachable from the host.
"forwardPorts": [8080],
"remoteEnv": {
"CONFIG_PATH": "${containerWorkspaceFolder}/config/dev.yaml"
}
}

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.go]
indent_style = tab
indent_size = 4
[*.{yaml,yml,json,toml}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false

37
.githooks/pre-push Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# pre-push hook: runs quality checks before every `git push`.
# Install with: make setup (sets core.hooksPath = .githooks)
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
# shellcheck source=../tools.versions
source "${REPO_ROOT}/tools.versions"
echo "pre-push: running checks..."
# ── 1. gofmt ──────────────────────────────────────────────────────────────────
echo " → gofmt"
UNFORMATTED=$(gofmt -l $(git ls-files '*.go'))
if [ -n "$UNFORMATTED" ]; then
echo " FAIL: the following files are not gofmt-formatted:"
echo "$UNFORMATTED" | sed 's/^/ /'
echo " Fix with: make lint-fix"
exit 1
fi
# ── 2. go vet ─────────────────────────────────────────────────────────────────
echo " → go vet"
go vet ./...
# ── 3. golangci-lint ──────────────────────────────────────────────────────────
echo " → golangci-lint"
go run github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} run ./...
# ── 4. gosec (security scan) ──────────────────────────────────────────────────
echo " → gosec"
go run github.com/securego/gosec/v2/cmd/gosec@${GOSEC_VERSION} -quiet ./...
# govulncheck is intentionally omitted (network + slow).
# Run it manually with: make security
echo "pre-push: all checks passed."

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test output
*.out
coverage.html
coverage.txt
# Editor & OS
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Environment files (never commit secrets)
.env
.env.local
*.env
# Go workspace (opt-in per-developer)
go.work
go.work.sum
# Vendor directory (use go modules instead)
vendor/
# Build artifacts
dist/

67
.golangci.yml Normal file
View File

@ -0,0 +1,67 @@
# golangci-lint configuration
# Tuned for PoC / hobby projects: catches real bugs without noise.
# Docs: https://golangci-lint.run/usage/configuration/
run:
timeout: 5m
go: '1.25'
# Enable specific linters on top of the default set.
# Default set: errcheck, gosimple, govet, ineffassign, staticcheck, unused
linters:
enable:
- gofmt # enforce gofmt formatting
- goimports # enforce import grouping (stdlib / external / internal)
- misspell # catch common English spelling mistakes in comments
- bodyclose # HTTP response body must be closed
- noctx # HTTP requests should use context
- nolintlint # prevent unexplained //nolint directives
- gocritic # opinionated but practical style checks
- gosec # security-oriented checks (CWE coverage)
- prealloc # suggest pre-allocation for slices
- unconvert # remove unnecessary type conversions
- unparam # flag unused function parameters
- whitespace # leading/trailing blank lines in blocks
# Linters disabled by default that are too noisy for PoC work:
# - godot (dot at end of every comment — very pedantic)
# - wsl (whitespace linter — very strict)
# - dupl (duplicate code detection — high false-positive rate)
# - funlen (function length limits — impractical for exploratory code)
# - gocyclo (cyclomatic complexity — add back when code matures)
# - gomnd (magic number detection — too noisy early on)
# - exhaustive (enum switch exhaustiveness — useful later)
linters-settings:
gofmt:
simplify: true
goimports:
# Put local module imports in their own group (after stdlib and external).
local-prefixes: gitea.djmil.dev/go/template
gocritic:
disabled-checks:
- ifElseChain # chains are often more readable in switch-less code
gosec:
excludes:
- G104 # Errors unhandled — already covered by errcheck
- G304 # File path from variable — common in config loading
misspell:
locale: US
unparam:
check-exported: false # exported API params are intentional
issues:
exclude-rules:
# Don't flag test files for some linters.
- path: _test\.go
linters:
- unparam
- gocritic
- gosec
max-issues-per-linter: 50
max-same-issues: 5

43
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,43 @@
{
"version": "0.2.0",
"configurations": [
{
// Run: equivalent to `make run`
"name": "Run: app",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/app",
"args": ["-env", "dev", "-log-level", "debug", "-name", "Tester"]
},
{
// Debug: same as above but with delve attached
"name": "Debug: app",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/app",
"args": ["-env", "dev", "-log-level", "debug"],
"showLog": true,
"trace": "verbose"
},
{
// Test: run tests for the currently open file
"name": "Test: current file",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${fileDirname}",
"args": ["-v", "-run", "Test"]
},
{
// Test: run a single named test (edit the -run value)
"name": "Test: single (edit -run arg)",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${fileDirname}",
"args": ["-v", "-run", "TestGreet"]
}
]
}

42
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// Go
"go.useLanguageServer": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"go.lintOnSave": "workspace",
"go.testFlags": ["-race"],
"go.coverOnSave": false,
"go.generateOnSave": false,
// Editor
"[go]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "golang.go",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
// Files
"files.exclude": {
"**/bin": true,
"**/.git": true
},
"search.exclude": {
"**/bin": true,
"**/mocks": true,
"**/vendor": true
},
// Test explorer
"go.testExplorer.enable": true,
"makefile.configureOnOpen": false,
"cSpell.words": [
"djmil",
"gitea",
"testutil"
]
}

59
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,59 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "make build",
"group": { "kind": "build", "isDefault": true },
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$go"
},
{
"label": "run",
"type": "shell",
"command": "make run",
"group": "none",
"presentation": { "reveal": "always", "panel": "dedicated" },
"isBackground": false
},
{
"label": "test",
"type": "shell",
"command": "make test",
"group": { "kind": "test", "isDefault": true },
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$go"
},
{
"label": "test (race detector)",
"type": "shell",
"command": "make test-race",
"group": "test",
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$go"
},
{
"label": "lint",
"type": "shell",
"command": "make lint",
"group": "none",
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$go"
},
{
"label": "mocks",
"type": "shell",
"command": "make mocks",
"group": "none",
"presentation": { "reveal": "always", "panel": "shared" }
},
{
"label": "security scan",
"type": "shell",
"command": "make security",
"group": "none",
"presentation": { "reveal": "always", "panel": "shared" }
}
]
}

122
CLAUDE.md Normal file
View File

@ -0,0 +1,122 @@
# CLAUDE.md — Agent Instructions
This file is read automatically by Claude Code at the start of every session.
Keep it concise — the agent needs signal, not essays.
---
## Project overview
Go 1.25 template for PoC, hobby projects, and small publishable packages.
Demonstrates: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes,
result type (happy-path error handling), linting (golangci-lint), security scanning (gosec, govulncheck),
git hooks, devcontainer, VSCode tasks.
Key constraint: `go.mod` stays free of dev tool deps (tools are pinned in `tools.versions` and run via
`go run tool@version`) so packages published from this repo have a clean module graph for consumers.
Module: `gitea.djmil.dev/go/template` — update this when you fork.
---
## Project structure
```
cmd/app/main.go composition root — wires deps, no logic here
internal/config/ flag-based config loader (config.Load)
internal/logger/ slog wrapper with WithField / WithFields
internal/greeter/ Example domain package (delete or repurpose)
pkg/result/ Example publishable package (Result/Expect types)
tools.versions Pinned tool versions (sourced by Makefile and pre-push hook)
.golangci.yml Linter rules
.githooks/pre-push Runs gofmt + go vet + golangci-lint + gosec before push
```
---
## Project rules
- **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/`
- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
- **Errors** — wrap with `fmt.Errorf("context: %w", err)`; never swallow errors silently
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
---
## Code style
- Follow `gofmt` + `goimports` formatting (enforced by linter and git hook)
- Imports: stdlib → blank line → external → blank line → internal (goimports handles this)
- Error variables: `err` for local, `ErrFoo` for package-level sentinels
- Constructors: `New(deps...) *Type` pattern
- Comment every exported symbol (golangci-lint will warn if missing)
- Max line length: 120 chars (configured in `.golangci.yml`)
- Prefer explicit over clever; PoC code should be readable first
---
## Testing rules
- Tests use only the standard `testing` package — no third-party assertion libraries
- Test files: `package foo_test` (black-box) unless white-box access is needed
- Fake dependencies with **manual fakes** (implement the interface inline in `_test.go`)
- Use `logger.NewNop()` when the test doesn't care about log output
- Table-driven tests with `t.Run("description", ...)` for multiple cases
- The race detector is enabled in CI (`make test-race`); don't introduce data races
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
---
## Development commands
```bash
make init # first-time setup: fetch deps, configure git hooks
make tools # install tool binaries to GOPATH/bin (versions from tools.versions)
make build # compile to ./bin/app
make run # go run with default flags
make test # run all tests
make test-race # tests + race detector
make lint # go vet + golangci-lint
make lint-fix # go fix + golangci-lint auto-fix
make security # gosec + govulncheck
make release # list releases, or tag+push after full checks (make release VERSION=v0.1.0)
make clean # remove bin/
```
VSCode: `Ctrl+Shift+B` = build, `Ctrl+Shift+T` = test.
Debug: use launch config "Debug: app" (F5).
---
## Adding new features (checklist)
1. Write the implementation in `internal/<domain>/` — return a concrete `*Type`, no interface at the implementation site
2. In the *consumer* package (or `_test.go`), declare a minimal interface covering only the methods you call
3. Write unit tests using a manual fake that satisfies that interface
4. Wire the concrete type in `cmd/app/main.go`
5. Run `make lint test` before committing
---
## Known pitfalls
- `govulncheck` makes network calls; excluded from pre-push hook (run manually)
- Tool versions live in `tools.versions` — edit that file to upgrade, both Makefile and hook pick it up
- `go run tool@version` is used in lint/security targets; Go caches downloads so subsequent runs are fast
- `make tools` installs binaries to `GOPATH/bin` for IDE integration (e.g. dlv for the debugger)
- `go fix` rewrites source files; run `make lint-fix` before committing after a Go version bump
---
## Recent work
<!-- Agent: append a dated bullet when completing a significant chunk of work.
Keep this section to ~10 entries; remove stale items.
Format: YYYY-MM-DD — what was done and why. -->
- 2026-03-29 — Stripped to stdlib-only: removed testify/mockery→manual fakes, zap→slog, viper→flag.
- 2026-03-29 — Pre-commit hook moved to pre-push; go vet + go fix added to lint pipeline.
- 2026-03-29 — Fixed stale docs and .golangci.yml local-prefixes; .vscode launch configs use CLI flags.
- 2026-04-01 — Replaced tools.go/go.mod pinning with tools.versions + go run tool@version; go.mod is now free of dev tool deps.
- 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes.

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 Andriy Djmil
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

88
Makefile Normal file
View File

@ -0,0 +1,88 @@
.PHONY: help init setup build run test test-race lint lint-fix security clean
include tools.versions
# ── Variables ──────────────────────────────────────────────────────────────────
BINARY_NAME := app
BINARY_PATH := ./bin/$(BINARY_NAME)
CMD_PATH := ./cmd/app
# ── Default target ─────────────────────────────────────────────────────────────
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' | sort
# ── First-time setup ───────────────────────────────────────────────────────────
init: ## First-time project init: fetch deps, configure git hooks
go mod tidy
$(MAKE) setup
@echo "Done! Run 'make build' to verify."
setup: ## Configure git to use .githooks directory
git config core.hooksPath .githooks
chmod +x .githooks/*
@echo "Git hooks configured: .githooks/"
# ── Development tools ──────────────────────────────────────────────────────────
tools: ## Install tool binaries to GOPATH/bin (versions from tools.versions)
@echo "Installing tools..."
go install github.com/go-delve/delve/cmd/dlv@$(DELVE_VERSION)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)
go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
# ── Build ──────────────────────────────────────────────────────────────────────
build: ## Compile the binary to ./bin/
go build -o $(BINARY_PATH) $(CMD_PATH)
# ── Run ────────────────────────────────────────────────────────────────────────
run: ## Run the application with default flags
go run $(CMD_PATH)/main.go
# ── Test ───────────────────────────────────────────────────────────────────────
test: ## Run all tests
go test ./... -cover
test-race: ## Run all tests with race detector
go test ./... -race -cover
test-verbose: ## Run all tests with verbose output
go test ./... -race -cover -v
# ── Lint & Security ────────────────────────────────────────────────────────────
lint: ## Run go vet + golangci-lint
go vet ./...
go run github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run ./...
lint-fix: ## Apply go fix + golangci-lint auto-fix
go fix ./...
go run github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --fix ./...
security: ## Run gosec + govulncheck
@echo "--- gosec ---"
go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -quiet ./...
@echo "--- govulncheck ---"
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
# ── Release ────────────────────────────────────────────────────────────────────
release: ## List releases, or tag+push a new one (usage: make release VERSION=v0.1.0)
ifdef VERSION
@echo "$(VERSION)" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \
(echo "VERSION must be semver: v0.1.0"; exit 1)
@git diff --quiet && git diff --cached --quiet || \
(echo "Uncommitted changes — commit first"; exit 1)
@git tag -l | grep -q "^$(VERSION)$$" && \
(echo "Tag $(VERSION) already exists"; exit 1) || true
$(MAKE) test-race
$(MAKE) lint
$(MAKE) security
git tag $(VERSION)
git push origin $(VERSION)
else
@echo "Released versions:"
@git tag -l --sort=-version:refname | grep -E '^v[0-9]' || echo " (none yet)"
endif
# ── Cleanup ────────────────────────────────────────────────────────────────────
clean: ## Remove build artifacts
rm -rf ./bin

302
README.md Normal file
View File

@ -0,0 +1,302 @@
# template
A Go project template for **PoC, hobby projects, and small publishable packages**.
Clone it, rename the module, run `make init`, and you're coding.
Demonstrates idiomatic Go patterns: stdlib-only dependencies, consumer-defined interfaces,
manual fakes for testing, and a clean `go.mod` that stays free of dev tool noise —
so packages extracted from this template can be published without module graph pollution.
---
## Features
| Area | Tool | Purpose |
|---|---|---|
| Language | Go 1.25 | Modules, toolchain directive |
| Logging | standard `log/slog` | Structured JSON/text logging + `WithField` extension |
| Config | standard `flag` | CLI flags with defaults, no config files |
| Linting | [golangci-lint](https://golangci-lint.run) | Aggregated linters, one config file |
| Security | [gosec](https://github.com/securego/gosec) + [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) | SAST + dependency vulnerability scan |
| Tests | standard `testing` | Table-driven tests, manual fakes, no third-party test framework |
| Git hooks | custom pre-push | gofmt + go vet + golangci-lint + gosec on every push |
| Tool versions | `tools.versions` + `go run` | Pinned versions without polluting `go.mod` |
| Dev environment | devcontainer | Reproducible VSCode / GitHub Codespaces setup |
| IDE | VSCode | Launch configs, tasks, recommended settings |
---
## Getting started
### Prerequisites
- Go 1.25+
- Git
- (Optional) [VSCode](https://code.visualstudio.com/) + [Go extension](https://marketplace.visualstudio.com/items?itemName=golang.Go)
- (Optional) Docker for devcontainer
### 1. Clone and rename
```bash
git clone https://gitea.djmil.dev/go/template my-project
cd my-project
# Interactive rename — updates module path, config, devcontainer, and docs:
./rename.sh
```
### 2. Init (run once)
```bash
make init # fetches deps, configures git hooks
make tools # (optional) install tool binaries to GOPATH/bin for IDE integration
```
### 3. Build and run
```bash
make build # compiles to ./bin/app
make run # go run with default flags
```
---
## Daily workflow
```bash
make test # run all tests
make test-race # … with race detector
make lint # go vet + golangci-lint
make lint-fix # go fix + golangci-lint auto-fix
make security # gosec + govulncheck
make release # list tags, or run full checks then tag+push (make release VERSION=v0.1.0)
```
> **Keyboard shortcut (VSCode):** `Ctrl+Shift+B` → build, `Ctrl+Shift+T` → test.
---
## Project structure
```
.
├── cmd/
│ └── app/
│ └── main.go # Composition root (thin — just wiring)
├── internal/
│ ├── config/ # flag-based config (config.Load)
│ ├── logger/ # slog wrapper with WithField / WithFields
│ └── greeter/ # Example domain package (replace with yours)
├── .devcontainer/ # VSCode / Codespaces container definition
├── .githooks/ # pre-push hook (installed by `make setup`)
├── .vscode/ # launch, tasks, editor settings
├── pkg/
│ └── result/ # Example publishable package (Result/Expect types)
│ ├── doc.go # package-level godoc
│ ├── result.go # implementation
│ └── example_test.go # runnable examples (shown on pkg.go.dev)
├── tools.versions # Pinned tool versions (Makefile + hook source this)
├── .golangci.yml # Linter configuration
└── Makefile # All development commands
```
---
## Configuration
All configuration is via CLI flags with sensible defaults:
```bash
./bin/app -help
-env string environment: dev | staging | prod (default "dev")
-log-level string log level: debug | info | warn | error (default "info")
-name string greeter name (default "Gopher")
-port int listen port (default 8080)
```
Override at runtime:
```bash
./bin/app -port 9090 -env prod -log-level warn
```
---
## Logging
The logger wrapper adds `WithField` and `WithFields` for ergonomic context chaining
on top of the standard `log/slog` package:
```go
log.WithField("request_id", rid).
WithField("user", uid).
Info("handling request")
log.WithFields(map[string]any{
"component": "greeter",
"name": name,
}).Debug("generating greeting")
```
In production (`app.env != dev`) the output is JSON (`slog.NewJSONHandler`).
In development, `logger.NewDevelopment()` uses the human-friendly text handler.
---
## Testing
Tests use the standard `testing` package. Concrete types are returned from
constructors — consumers (including tests) define their own minimal interfaces
and satisfy them with manual fakes. No code generation required.
```go
// Interface declared in the consumer (or _test.go), not in greeter package.
type greeter interface {
Greet(name string) result.Expect[string]
}
type fakeGreeter struct {
greetFn func(name string) result.Expect[string]
}
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
return f.greetFn(name)
}
func TestSomething(t *testing.T) {
fake := &fakeGreeter{
greetFn: func(name string) result.Expect[string] {
return result.Ok("Hello, " + name + "!")
},
}
// pass fake to the system under test
}
```
---
## pkg/result — happy-path error handling
`pkg/result` is a small publishable package that demonstrates the pattern of
keeping `go.mod` clean. It provides `Expect[T]`, a generic type that holds
either a success value or an error.
The idea: deep call stacks write for the **happy path**, unwrapping results with
`Expect` or `Must` (which panic on failure). A single `defer result.Catch(&err)`
at the entry point recovers those panics and returns them as a normal Go error,
with the stack trace captured at the original failure site.
```go
// Wrap any (value, error) function:
data := result.Of(os.ReadFile("config.json")).Expect("read config")
// Or construct explicitly:
func parsePort(s string) result.Expect[int] {
n, err := strconv.Atoi(s)
if err != nil {
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
}
return result.Ok(n)
}
// Entry point catches panics from the whole call stack:
func run() (err error) {
defer result.Catch(&err)
port := parsePort(cfg.Port).Expect("load config")
_ = port // happy path continues …
return nil
}
```
`Catch` only intercepts error panics produced by this package. Real runtime
panics (nil-pointer dereferences, etc.) are re-panicked so bugs are never
silently swallowed.
At boundaries where you need to re-join normal Go code:
```go
port, err := parsePort("443").Unwrap() // back to (T, error)
```
See [`pkg/result/example_test.go`](pkg/result/example_test.go) for the
full set of runnable examples.
---
## Git hooks
The pre-push hook runs **gofmt + go vet + golangci-lint + gosec** against the
full codebase before every `git push`. Running on push (not commit) keeps the
inner commit loop fast while still blocking bad code from reaching the remote.
`govulncheck` is intentionally excluded (it makes network calls and can be
slow). Run it manually with `make security`.
To skip the hook in an emergency:
```bash
git push --no-verify
```
---
## Tool version management
Tool versions are pinned in `tools.versions` — a plain `KEY=value` file that
both the `Makefile` and the pre-push hook source directly.
**Why not `tools.go` + `go.mod`?**
The conventional `//go:build tools` approach adds development tools (linters,
scanners, debuggers) as entries in `go.mod`. This is fine for applications, but
it's a problem when the repo is intended to publish reusable packages: consumers
who `go get` your package see those tool dependencies in the module graph, even
though they are never compiled into the binary. Keeping `go.mod` clean makes the
module honest — it declares only what the package code actually needs.
Instead, `go run tool@version` is used in Makefile targets and the pre-push
hook. Go caches downloaded tools in the module cache, so after the first run
there is no extra download cost.
To update a tool, edit the version in `tools.versions`:
```
GOLANGCI_LINT_VERSION=v1.64.8 ← change this
```
Both `make lint` and the pre-push hook pick up the new version automatically.
Run `make tools` if you also want the updated binary installed to `GOPATH/bin`
(e.g. for IDE integration or direct CLI use).
---
## Releasing
```bash
make release # list all existing tags
make release VERSION=v0.1.0 # run full checks, then tag and push
```
The release target validates the version format (semver `vX.Y.Z`), ensures the
working tree is clean, and runs `make test-race lint security` before tagging.
The tag is only created and pushed if every check passes.
---
## Devcontainer
Open this repo in VSCode and choose **"Reopen in Container"** — the Go
toolchain is installed automatically. Run `make init` to configure git hooks,
then `make tools` to install linter/scanner binaries to `GOPATH/bin`.
Works with GitHub Codespaces out of the box.
---
## Next steps (not included)
- **HTTP server** — add [chi](https://github.com/go-chi/chi) or [gin](https://github.com/gin-gonic/gin)
- **Database** — add [sqlx](https://github.com/jmoiron/sqlx) or [ent](https://entgo.io/)
- **CI** — add a Gitea Actions / CI pipeline running `make lint test security`
- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image (separate from the devcontainer)
- **OpenTelemetry** — add tracing with `go.opentelemetry.io/otel`

57
cmd/app/main.go Normal file
View File

@ -0,0 +1,57 @@
// main is the composition root for the application.
// It wires together config, logger, and domain services — nothing more.
// Business logic lives in internal/; cmd/ is deliberately thin.
package main
import (
"fmt"
"os"
"path/filepath"
"gitea.djmil.dev/go/template/internal/config"
"gitea.djmil.dev/go/template/internal/greeter"
"gitea.djmil.dev/go/template/internal/logger"
"gitea.djmil.dev/go/template/pkg/result"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
if stack := result.StackTrace(err); stack != "" {
fmt.Fprintf(os.Stderr, "%s\n", stack)
}
os.Exit(1)
}
}
func run() (err error) {
defer result.Catch(&err)
// ── Config ────────────────────────────────────────────────────────────────
cfg := config.Load()
// ── Logger ────────────────────────────────────────────────────────────────
var log *logger.Logger
if cfg.App.Env == "dev" {
log = logger.NewDevelopment()
} else {
log = result.Of(logger.New(cfg.Logger.Level)).Expect("create logger")
}
log.WithFields(map[string]any{
"app": filepath.Base(os.Args[0]),
"env": cfg.App.Env,
}).Info("starting up")
// ── Services ──────────────────────────────────────────────────────────────
var greetSvc greeter.Greeter = greeter.New(log)
// ── Example usage ─────────────────────────────────────────────────────────
msg := greetSvc.Greet(cfg.Greeter.Name).Expect("greeting")
log.WithField("message", msg).Info("greeting complete")
fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port)
return nil
}

7
go.mod Normal file
View File

@ -0,0 +1,7 @@
module gitea.djmil.dev/go/template
go 1.25.0
// Tool versions are pinned in tools.versions, not here keeping this file
// free of dev tool deps so published packages have a clean module graph.
// After cloning, rename the module path above with: ./rename.sh

0
go.sum Normal file
View File

61
internal/config/config.go Normal file
View File

@ -0,0 +1,61 @@
// Package config parses application configuration from command-line flags.
// Defaults are defined here; override at runtime with flags:
//
// ./app -port 9090 -env prod -log-level warn
//
// Usage:
//
// cfg := config.Load()
// fmt.Println(cfg.App.Port)
package config
import (
"flag"
)
// Config is the root configuration object. Add sub-structs as the app grows.
type Config struct {
App AppConfig
Logger LoggerConfig
Greeter GreeterConfig
}
// AppConfig holds generic application settings.
type AppConfig struct {
Port int
Env string // dev | staging | prod
}
// LoggerConfig controls logging behavior.
type LoggerConfig struct {
Level string // debug | info | warn | error
}
// Greeter config for internal/greeter/Service.
type GreeterConfig struct {
Name string
}
// Load parses command-line flags and returns a Config.
// Call this once at startup before any other flag parsing.
func Load() *Config {
name := flag.String("name", "Gopher", "application name")
port := flag.Int("port", 8080, "listen port")
env := flag.String("env", "dev", "environment: dev | staging | prod")
level := flag.String("log-level", "info", "log level: debug | info | warn | error")
flag.Parse()
return &Config{
App: AppConfig{
Port: *port,
Env: *env,
},
Logger: LoggerConfig{
Level: *level,
},
Greeter: GreeterConfig{
Name: *name,
},
}
}

View File

@ -0,0 +1,48 @@
// Package greeter is a minimal example domain package.
// It demonstrates how to:
// - define an interface (satisfied by manual fakes in tests)
// - inject dependencies (logger) through a constructor
// - use the logger.WithField pattern
//
// Replace this package with your own domain logic.
package greeter
import (
"errors"
"fmt"
"gitea.djmil.dev/go/template/internal/logger"
"gitea.djmil.dev/go/template/pkg/result"
)
// Greeter produces a greeting for a given name.
// The interface is what other packages should depend on — never the concrete type.
type Greeter interface {
Greet(name string) result.Expect[string]
}
// Service is the concrete implementation.
type Service struct {
log *logger.Logger
}
// New creates a Greeter service with the provided logger.
func New(log *logger.Logger) *Service {
return &Service{log: log}
}
// Greet returns a personalized greeting and logs the interaction.
func (s *Service) Greet(name string) result.Expect[string] {
if name == "" {
return result.Fail[string](errors.New("Greet: name must not be empty"))
}
msg := fmt.Sprintf("Hello, %s!", name)
s.log.
WithField("component", "greeter").
WithField("name", name).
Info("greeting generated")
return result.Ok(msg)
}

View File

@ -0,0 +1,50 @@
package greeter_test
import (
"testing"
"gitea.djmil.dev/go/template/internal/greeter"
"gitea.djmil.dev/go/template/internal/logger"
"gitea.djmil.dev/go/template/internal/testutil"
"gitea.djmil.dev/go/template/pkg/result"
)
// ── Service (unit tests) ──────────────────────────────────────────────────────
func TestGreet(t *testing.T) {
svc := greeter.New(logger.NewNop())
t.Run("returns personalized greeting", func(t *testing.T) {
msg, err := svc.Greet("World").Unwrap()
testutil.NoError(t, err)
testutil.Equal(t, msg, "Hello, World!")
})
t.Run("rejects empty name", func(t *testing.T) {
testutil.ErrorContains(t, svc.Greet("").Err(), "name must not be empty")
})
}
// ── Manual fake example ───────────────────────────────────────────────────────
// For consumers that depend on the Greeter interface, write a manual fake.
// No code generation required — just implement the interface directly.
type fakeGreeter struct {
greetFn func(name string) result.Expect[string]
}
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
return f.greetFn(name)
}
func TestFakeUsageExample(t *testing.T) {
fake := &fakeGreeter{
greetFn: func(name string) result.Expect[string] {
return result.Ok("Hello, " + name + "!")
},
}
msg, err := fake.Greet("Alice").Unwrap()
testutil.NoError(t, err)
testutil.Equal(t, msg, "Hello, Alice!")
}

81
internal/logger/logger.go Normal file
View File

@ -0,0 +1,81 @@
// Package logger wraps log/slog with a thin, ergonomic API.
//
// The key addition over raw slog is the WithField / WithFields helpers that
// return a *Logger (not a *slog.Logger), so callers stay in the typed world
// and can chain field attachments without importing slog directly.
//
// Usage:
//
// log, _ := logger.New("info")
// log.Info("server started")
//
// req := log.WithField("request_id", rid).WithField("user_id", uid)
// req.Info("handling request")
package logger
import (
"fmt"
"io"
"log/slog"
"os"
)
// Logger is a thin wrapper around *slog.Logger.
// All slog methods (Info, Error, Debug, Warn, …) are available via embedding.
type Logger struct {
*slog.Logger
}
// New creates a JSON logger writing to stderr for the given level string.
// Valid levels: debug, info, warn, error.
func New(level string) (*Logger, error) {
lvl, err := parseLevel(level)
if err != nil {
return nil, err
}
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
return &Logger{slog.New(h)}, nil
}
// NewDevelopment creates a human-friendly text logger writing to stderr.
// Use this in local dev; prefer New() in any deployed environment.
func NewDevelopment() *Logger {
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
return &Logger{slog.New(h)}
}
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
func NewNop() *Logger {
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
}
// WithField returns a child logger that always includes key=value in every log line.
func (l *Logger) WithField(key string, value any) *Logger {
return &Logger{l.Logger.With(key, value)}
}
// WithFields returns a child logger enriched with every key/value in fields.
// Prefer WithField for one or two fields; use WithFields for structured context
// objects (e.g. attaching a request span).
func (l *Logger) WithFields(fields map[string]any) *Logger {
args := make([]any, 0, len(fields)*2)
for k, v := range fields {
args = append(args, k, v)
}
return &Logger{l.Logger.With(args...)}
}
// ── helpers ───────────────────────────────────────────────────────────────────
func parseLevel(level string) (slog.Level, error) {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return lvl, fmt.Errorf("logger: unknown level %q (use debug|info|warn|error)", level)
}
return lvl, nil
}

View File

@ -0,0 +1,44 @@
// Package testutil provides lightweight test helpers to reduce boilerplate in
// table-driven tests. Import it from any _test.go file in this module.
//
// Every helper calls t.Helper() so failures are reported at the call site, not
// inside this package.
package testutil
import (
"strings"
"testing"
)
// NoError fails the test immediately if err is not nil.
func NoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Error fails the test if err is nil.
func Error(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
}
// ErrorContains fails the test if err is nil or its message does not contain substr.
func ErrorContains(t *testing.T, err error, substr string) {
t.Helper()
Error(t, err)
if !strings.Contains(err.Error(), substr) {
t.Errorf("error %q does not contain %q", err.Error(), substr)
}
}
// Equal fails the test if got != want.
func Equal[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}

33
pkg/result/doc.go Normal file
View File

@ -0,0 +1,33 @@
// Package result provides a generic Expect[T] type for happy-path-oriented code.
//
// The intended pattern is:
//
// 1. Deep call stacks write for the happy path, using [Expect.Must] or
// [Expect.Expect] to unwrap values — panicking on unexpected errors rather
// than threading error returns through every frame.
// 2. The function that initiates a complex operation defers [Catch], which
// recovers any Expect panic and returns it as a normal Go error.
//
// Stack traces are captured at the panic site and can be retrieved from the
// caught error via [StackTrace].
//
// # Constructors
//
// Use [Ok] to wrap a success value, [Fail] to wrap an error, and [Of] to
// bridge existing (value, error) return signatures:
//
// data := result.Of(os.ReadFile("cfg.json")).Expect("read config")
//
// # Boundary pattern
//
// func run() (err error) {
// defer result.Catch(&err)
// port := parsePort(cfg.Port).Expect("load config port")
// _ = port // happy path continues …
// return nil
// }
//
// [Catch] only intercepts error panics produced by this package. Real runtime
// panics (nil-pointer dereferences, index out of bounds, etc.) are re-panicked
// so genuine bugs are never silently swallowed.
package result

117
pkg/result/example_test.go Normal file
View File

@ -0,0 +1,117 @@
package result_test
import (
"errors"
"fmt"
"strconv"
"gitea.djmil.dev/go/template/pkg/result"
)
// parsePort wraps strconv.Atoi so callers can use the happy-path style.
func parsePort(s string) result.Expect[int] {
n, err := strconv.Atoi(s)
if err != nil {
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
}
if n < 1 || n > 65535 {
return result.Fail[int](fmt.Errorf("parsePort: %d out of range", n))
}
return result.Ok(n)
}
// Example_happyPath shows the basic happy-path pattern: call Expect at each
// step; if anything fails the panic unwinds to the nearest Catch.
func Example_happyPath() {
port := parsePort("8080").Expect("read port")
fmt.Println(port)
// Output:
// 8080
}
// Example_errCheck shows checking the error without panicking — useful at the
// outermost boundary where you want a normal error return.
func Example_errCheck() {
r := parsePort("not-a-number")
if r.Err() != nil {
fmt.Println("failed:", r.Err())
}
// Output:
// failed: parsePort: strconv.Atoi: parsing "not-a-number": invalid syntax
}
// Example_of shows wrapping an existing (value, error) function with result.Of.
func Example_of() {
port := result.Of(strconv.Atoi("9090")).Expect("parse port")
fmt.Println(port)
// Output:
// 9090
}
// Example_catch shows the boundary pattern: defer Catch at the entry point so
// any Expect/Must panic anywhere in the call stack is captured as a normal
// error return.
func Example_catch() {
run := func() (err error) {
defer result.Catch(&err)
// Simulate a deep call stack: this panics because the port is invalid.
_ = parsePort("99999").Expect("load config port")
return nil
}
if err := run(); err != nil {
fmt.Println("caught:", err)
}
// Output:
// caught: load config port: parsePort: 99999 out of range
}
// Example_unwrap shows re-joining the normal Go (value, error) world at a
// boundary where both values are needed separately.
func Example_unwrap() {
port, err := parsePort("443").Unwrap()
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(port)
// Output:
// 443
}
// Example_nonErrorPanic shows that Catch does NOT swallow genuine runtime
// panics — only error panics produced by Must/Expect are captured.
func Example_nonErrorPanic() {
safeRun := func() (err error) {
defer func() {
// Outer recover catches the re-panic from Catch.
if v := recover(); v != nil {
err = fmt.Errorf("non-error panic: %v", v)
}
}()
defer result.Catch(&err)
panic("unexpected runtime problem") // not an error — Catch re-panics
}
if err := safeRun(); err != nil {
fmt.Println(err)
}
// Output:
// non-error panic: unexpected runtime problem
}
// Example_fail shows constructing a failed Expect explicitly, e.g. when a
// function detects an error condition before calling any fallible op.
func Example_fail() {
validate := func(name string) result.Expect[string] {
if name == "" {
return result.Fail[string](errors.New("name must not be empty"))
}
return result.Ok(name)
}
r := validate("")
fmt.Println(r.Err())
// Output:
// name must not be empty
}

110
pkg/result/result.go Normal file
View File

@ -0,0 +1,110 @@
package result
import (
"errors"
"fmt"
"runtime/debug"
)
// Expect holds either a value of type T or an error.
type Expect[T any] struct {
value T
err error
}
// Ok wraps a successful value in an Expect.
func Ok[T any](v T) Expect[T] {
return Expect[T]{value: v}
}
// Fail wraps an error in an Expect.
func Fail[T any](err error) Expect[T] {
return Expect[T]{err: err}
}
// Of is a convenience constructor that bridges standard Go (value, error)
// return signatures:
//
// result.Of(os.Open("file.txt")).Expect("open config")
func Of[T any](v T, err error) Expect[T] {
return Expect[T]{value: v, err: err}
}
// Err returns the wrapped error, or nil on success.
func (r Expect[T]) Err() error {
return r.err
}
// Must returns the value or panics with the wrapped error and a stack trace.
// Prefer [Expect.Expect] — it adds a message that makes the panic site easy to
// locate in logs.
func (r Expect[T]) Must() T {
if r.err != nil {
panic(&stackError{err: r.err, stack: debug.Stack()})
}
return r.value
}
// Expect returns the value or panics with the error annotated by msg and a
// stack trace captured at this call site.
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: debug.Stack(),
})
}
return r.value
}
// Unwrap returns the value and error in the standard Go (value, error) form.
// Useful at the boundary where you want to re-join normal error-return code.
func (r Expect[T]) Unwrap() (T, error) {
return r.value, r.err
}
// Catch recovers a panic produced by [Expect.Must] or [Expect.Expect] and
// stores it in *errp. Call it via defer at the entry point of any function
// that runs a happy-path call stack:
//
// func run() (err error) {
// defer result.Catch(&err)
// ...
// }
//
// The stored error retains its stack trace; retrieve it with [StackTrace].
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked
// so genuine bugs are not silently swallowed.
func Catch(errp *error) {
v := recover()
if v == nil {
return
}
if err, ok := v.(error); ok {
*errp = err
return
}
panic(v) // not an error — let it propagate
}
// StackTrace returns the stack trace captured when [Expect.Expect] or
// [Expect.Must] panicked. Returns an empty string if err was not produced by
// this package.
func StackTrace(err error) string {
var s *stackError
if errors.As(err, &s) {
return string(s.stack)
}
return ""
}
// stackError wraps an error with a stack trace captured at the panic site.
type stackError struct {
err error
stack []byte
}
func (s *stackError) Error() string { return s.err.Error() }
func (s *stackError) Unwrap() error { return s.err }

222
rename.sh Executable file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env bash
# rename.sh — rename this template to your actual project.
#
# Usage:
# ./rename.sh # interactive prompts
# ./rename.sh acme-corp my-svc # non-interactive (org, project)
#
# What it changes:
# go.mod module path
# **/*.go import paths
# .devcontainer/devcontainer.json name field
# README.md heading + module path references
# CLAUDE.md Module line
# .golangci.yml goimports local-prefixes
# git tags all template tags deleted
# git history squashed into one INIT commit
set -euo pipefail
# ── Colour helpers ────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}!${RESET} $*"; }
error() { echo -e "${RED}${RESET} $*" >&2; }
heading() { echo -e "\n${BOLD}$*${RESET}"; }
# ── Default Git host ──────────────────────────────────────────────────────────
# Change this if you ever migrate to a different server.
DEFAULT_HOST="gitea.djmil.dev"
# ── Validation ────────────────────────────────────────────────────────────────
validate_slug() {
local val="$1" label="$2"
if [[ -z "$val" ]]; then
error "$label cannot be empty."
return 1
fi
if [[ ! "$val" =~ ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ ]]; then
error "$label must be lowercase alphanumeric (hyphens/dots/underscores allowed, no leading/trailing)."
return 1
fi
}
# ── Convert kebab-case / snake_case to Title Case ─────────────────────────────
to_title() {
echo "$1" | sed -E 's/[-_]/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2)} 1'
}
# ── Determine script's own directory (works with symlinks) ───────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── Check we're in the right repo ────────────────────────────────────────────
if [[ ! -f go.mod ]]; then
error "go.mod not found. Run this script from the project root."
exit 1
fi
CURRENT_MODULE=$(grep '^module ' go.mod | awk '{print $2}')
TEMPLATE_MODULE="${DEFAULT_HOST}/go/template"
if [[ "$CURRENT_MODULE" != "$TEMPLATE_MODULE" ]]; then
warn "Module is already '$CURRENT_MODULE' (not the default template value)."
warn "Continuing will replace '$CURRENT_MODULE' with your new path."
echo
fi
# ── Capture template version before touching anything ─────────────────────────
TEMPLATE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "untagged")
# ── Gather inputs ─────────────────────────────────────────────────────────────
heading "Go Template — Project Renamer"
echo "This script rewrites the module path and project name throughout the codebase."
echo
INTERACTIVE=true
if [[ $# -ge 2 ]]; then
NEW_ORG="$1"
NEW_PROJECT="$2"
INTERACTIVE=false
else
while true; do
read -rp "Org / username (e.g. djmil): " NEW_ORG
validate_slug "$NEW_ORG" "Org/username" && break
done
while true; do
read -rp "Project name (e.g. my-service): " NEW_PROJECT
validate_slug "$NEW_PROJECT" "Project name" && break
done
fi
validate_slug "$NEW_ORG" "Org/username"
validate_slug "$NEW_PROJECT" "Project name"
NEW_MODULE="${DEFAULT_HOST}/${NEW_ORG}/${NEW_PROJECT}"
OLD_MODULE="$CURRENT_MODULE"
OLD_PROJECT=$(basename "$OLD_MODULE") # e.g. username
NEW_DISPLAY=$(to_title "$NEW_PROJECT") # e.g. My Service
OLD_DISPLAY=$(to_title "$OLD_PROJECT") # e.g. Template
# ── Preview ───────────────────────────────────────────────────────────────────
heading "Changes to be applied"
printf " %-22s %s → %s\n" "Module path:" "$OLD_MODULE" "$NEW_MODULE"
printf " %-22s %s → %s\n" "Project name:" "$OLD_PROJECT" "$NEW_PROJECT"
printf " %-22s %s → %s\n" "Display name:" "$OLD_DISPLAY" "$NEW_DISPLAY"
printf " %-22s %s\n" "Template version:" "$TEMPLATE_TAG"
echo
if $INTERACTIVE; then
read -rp "Apply these changes? [y/N] " CONFIRM
case "$CONFIRM" in
[yY][eE][sS]|[yY]) ;;
*) echo "Aborted."; exit 0 ;;
esac
fi
# ── Helper: portable in-place sed ────────────────────────────────────────────
# macOS sed requires an extension argument for -i; GNU sed does not.
sedi() {
if sed --version &>/dev/null 2>&1; then
sed -i "$@"
else
sed -i '' "$@"
fi
}
# ── Apply substitutions ───────────────────────────────────────────────────────
heading "Applying changes"
# 1. go.mod — module declaration
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" go.mod
info "go.mod"
# 2. All Go source files — import paths
GO_FILES=$(find . \
-not -path './.git/*' \
-not -path './bin/*' \
-name '*.go' \
-type f)
CHANGED_GO=0
for f in $GO_FILES; do
if grep -q "$OLD_MODULE" "$f" 2>/dev/null; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$f"
CHANGED_GO=$((CHANGED_GO + 1))
fi
done
info "${CHANGED_GO} Go source file(s)"
# 3. .devcontainer/devcontainer.json — "name" field
if [[ -f .devcontainer/devcontainer.json ]]; then
sedi "s|\"name\": \"${OLD_DISPLAY}\"|\"name\": \"${NEW_DISPLAY}\"|g" \
.devcontainer/devcontainer.json
info ".devcontainer/devcontainer.json"
fi
# 4. README.md — heading + all module path occurrences
if [[ -f README.md ]]; then
sedi "s|^# ${OLD_PROJECT}$|# ${NEW_PROJECT}|g" README.md
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" README.md
info "README.md"
fi
# 5. CLAUDE.md — Module line
if [[ -f CLAUDE.md ]]; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" CLAUDE.md
info "CLAUDE.md"
fi
# 6. .golangci.yml — goimports local-prefixes
if [[ -f .golangci.yml ]]; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" .golangci.yml
info ".golangci.yml"
fi
# 7. git remote origin — rewrite URL preserving scheme (https or ssh)
if git remote get-url origin &>/dev/null 2>&1; then
OLD_REMOTE=$(git remote get-url origin)
if [[ "$OLD_REMOTE" == https://* ]]; then
NEW_REMOTE="https://${DEFAULT_HOST}/${NEW_ORG}/${NEW_PROJECT}.git"
else
# SSH form: git@host:org/project.git or ssh://git@host/org/project.git
NEW_REMOTE="git@${DEFAULT_HOST}:${NEW_ORG}/${NEW_PROJECT}.git"
fi
git remote set-url origin "$NEW_REMOTE"
info "git remote origin → ${NEW_REMOTE}"
else
warn "No 'origin' remote found — skipping remote update."
fi
# ── Squash git history into a single INIT commit ──────────────────────────────
heading "Git history"
# Stage rename changes, fold all commits back to root, rewrite as single commit.
git add -A
ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD)
git reset --soft "$ROOT_COMMIT"
git commit --amend -m "init: bootstrap ${NEW_PROJECT} from go-template ${TEMPLATE_TAG}
Initialized from ${OLD_MODULE} @ ${TEMPLATE_TAG}.
Module renamed to ${NEW_MODULE}."
info "History squashed → single INIT commit (template: ${TEMPLATE_TAG})"
# Delete all template tags — version history belongs to the template, not the fork.
TAGS=$(git tag)
if [[ -n "$TAGS" ]]; then
echo "$TAGS" | xargs git tag -d
TAG_COUNT=$(echo "$TAGS" | wc -l | tr -d '[:space:]')
info "Deleted ${TAG_COUNT} template tag(s)"
fi
# ── Post-rename suggestions ───────────────────────────────────────────────────
heading "Done"
echo "Module is now: ${BOLD}${NEW_MODULE}${RESET}"
echo
echo "Recommended next steps:"
echo " go mod tidy # sync go.sum after path change"
echo " make build # verify it compiles"
echo " make test # verify tests pass"
echo " git push -u origin main --force-with-lease"

4
tools.versions Normal file
View File

@ -0,0 +1,4 @@
DELVE_VERSION=v1.26.1
GOLANGCI_LINT_VERSION=v1.64.8
GOSEC_VERSION=v2.24.7
GOVULNCHECK_VERSION=v1.1.4