Repo Tooling Architecture
Productivity files that live at the repo root, are language-agnostic, and shape how every contributor and CI step interacts with the code. Pairs with every language and framework architect skill. See STACK.md for pinned tool versions.
Decision model
Add a tool when all three are true:
- It pays back on day one. Not "useful eventually" — useful from the first PR.
- The cost of installing it is smaller than the cost of going without it. A 30-second install is fine; a 30-minute setup that breaks once per OS is not.
- There's no language-native option that already covers it.
go testdoesn't need a Makefile wrapper.uv rundoesn't needtaskfor one-language projects.
When in doubt, skip. Tools you add later are easy; tools you remove are political.
1. .editorconfig
Always include. Universal editor support, zero ceremony, ends the indent-style wars before they start.
# .editorconfig
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{go,Makefile}]
indent_style = tab
[*.{yml,yaml,json}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false # Markdown uses trailing spaces for hard breaks
2. .gitignore
Always include. Curate per language; never copy a 500-line GitHub default that contains entries for languages you don't use.
- Use github.com/github/gitignore as the source for the base section per language.
- Trim aggressively. A clean
.gitignoreis documentation; a noisy one is noise. - Local-only exclusions (
.idea/,.vscode/settings.json) go in.git/info/excludeper-developer or~/.gitignore_global, not the repo's.gitignore.
3. Tool version pinning — mise (default), proto (alternative)
The problem: "works on my machine" because each dev has different Go / Python / Node versions. CI uses yet another version. Fix: pin every tool the repo needs in a file, install with one command.
Default: mise with mise.toml
# mise.toml
[tools]
go = "1.26"
python = "3.14"
node = "22"
task = "3.51"
golangci-lint = "2.12"
buf = "1.69"
trivy = "0.70"
mise install # installs everything pinned
- Why mise as default: broadest tool coverage via asdf-legacy plugins, available through
winget/brew/scoop. - Shell activation required:
eval "$(mise activate bash)"(or zsh/fish/pwsh equivalent).
Alternative: proto with .prototools
Same key-value shape, file named .prototools, install with proto install. Pick proto when you want strict separation (proto manages only binary versions; task handles env + tasks) and no shell-activation step (PATH-shim based). Trade-off: smaller plugin ecosystem; obscure tools may need a custom WASM plugin.
Either way: the pinned-versions file is committed; CI installs the same versions; nobody runs "whatever's on their machine."
4. Task runner — Task (default) or just (alternative)
The problem: every project has 5–15 commands a contributor needs to know (go build, go test --race, docker compose up -d db, task generate-protos, etc.). Without a runner, they live in stale README sections.
Default: skip a task runner for single-language projects. go build, uv run, npm are enough. Don't wrap them in task build: aliases — that's ceremony.
Add a task runner when: multi-language repo, multi-service compose stack, or a meaningful number of multi-step commands.
Default: Task with Taskfile.yml
# Taskfile.yml
version: '3'
dotenv: ['.env', '.env.local']
tasks:
default:
desc: List all tasks
cmds:
- task --list
proto:
desc: Regenerate Go code from .proto files
sources:
- 'proto/**/*.proto'
generates:
- 'internal/**/*.pb.go'
cmds:
- buf generate
test:
desc: Run all tests with race detector
cmds:
- go test -race ./...
up:
desc: Start dev stack (DB + app)
cmds:
- docker compose up -d
- Why Task as default: built-in
sources:/generates:for incremental builds,--watchmode, first-class Windows support (ships its own POSIX-sh interpreter), built-indotenv:loading. - Discoverability:
task --listshows every task with itsdesc.
Alternative: just with justfile
Makefile-style brevity over YAML; recipes look like shell. Add set windows-shell := [...] and set dotenv-load at the top. Pick just when you prefer terseness, don't need incremental builds, and all collaborators are on macOS/Linux. Trade-off: no native incremental builds, no --watch mode.
Settle on one per repo. Don't mix Taskfile.yml and justfile.
5. Pre-commit hooks — minimal, opt-in
The problem: minor issues (trailing whitespace, committed secrets, large files) reach CI when they could have been blocked locally. Fix: a tiny pre-commit hook set that catches only what can't be caught later or that's genuinely fast.
Strong opinions exist. Some teams love pre-commit; some find it slows commits / blocks WIP commits. The minimal-hook stance is the compromise: catch real risks, never run language-specific linters or formatters in commit hooks (those run in editor-on-save and again in CI).
.pre-commit-config.yaml — minimal set
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
exclude: '\.md$' # markdown uses trailing space for hard breaks
- id: end-of-file-fixer
- id: check-added-large-files
args: ['--maxkb=500']
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.0
hooks:
- id: gitleaks # secret detection
- Install:
pre-commit installonce per clone. - Run on demand:
pre-commit run --all-files(or wire into a Task:task lint:precommit). - No language-specific linters.
ruff,golangci-lint, etc. run in editor (on save) and again in CI. Running them in a pre-commit hook duplicates work and slows commits. - Skip on a single commit:
git commit --no-verifyis fine; over-policing pre-commit erodes trust.
6. Environment variables
The problem: secrets and config diverge across machines, leak into git, or get exported globally and bleed across projects.
Pattern
- Non-secret config:
.env+.env.local, loaded by Task'sdotenv:directive..envis committed (defaults safe for any contributor —LOG_LEVEL=info,DB_HOST=localhost)..env.localis git-ignored (per-developer overrides —LOG_LEVEL=debug).
- Real secrets: external secret manager. Never in
.env, never committed:- 1Password CLI (
op run --env-file=.env.template -- task up) — solo / small team - doppler (
doppler run -- task up) — team - Cloud secret manager (AWS Secrets Manager, GCP Secret Manager) — production injection at runtime
- 1Password CLI (
- No
direnvunless you need automatic activation when youcdinto a directory. Task'sdotenv:covers the common case without a second tool.
.env shape
# .env — committed, safe defaults
LOG_LEVEL=info
APP_PORT=8080
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_dev
# .env.local — git-ignored, per-developer overrides + dev secrets
LOG_LEVEL=debug
DB_PASSWORD=dev-only-not-a-real-secret
.gitignore must include .env.local. Audit any .env* pattern: only ignore variants that hold secrets; commit the safe ones.
7. Dependency updates — Renovate (default), Dependabot (acceptable)
The problem: dependency updates accumulate until they hit a security advisory or a breaking version, then a week of catch-up is needed. Automate it.
Default: Renovate
renovate.json at the repo root:
{
"$schema": "https://docs.renovate