init: go/template v0.1.1
This commit is contained in:
commit
8e7100a427
64
.devcontainer/devcontainer.json
Normal file
64
.devcontainer/devcontainer.json
Normal 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
21
.editorconfig
Normal 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
37
.githooks/pre-push
Executable 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
33
.gitignore
vendored
Normal 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
67
.golangci.yml
Normal 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
43
.vscode/launch.json
vendored
Normal 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
42
.vscode/settings.json
vendored
Normal 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
59
.vscode/tasks.json
vendored
Normal 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
122
CLAUDE.md
Normal 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
9
LICENSE
Normal 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
88
Makefile
Normal 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
302
README.md
Normal 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
57
cmd/app/main.go
Normal 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
7
go.mod
Normal 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
|
||||
61
internal/config/config.go
Normal file
61
internal/config/config.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
48
internal/greeter/greeter.go
Normal file
48
internal/greeter/greeter.go
Normal 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)
|
||||
}
|
||||
50
internal/greeter/greeter_test.go
Normal file
50
internal/greeter/greeter_test.go
Normal 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
81
internal/logger/logger.go
Normal 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
|
||||
}
|
||||
44
internal/testutil/testutil.go
Normal file
44
internal/testutil/testutil.go
Normal 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
33
pkg/result/doc.go
Normal 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
117
pkg/result/example_test.go
Normal 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
110
pkg/result/result.go
Normal 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
222
rename.sh
Executable 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
4
tools.versions
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user