Stage 7 · Master
Phase 1 — Project Foundation
Git Workflow
Turn branch names, commit messages, and a pre-commit hook into guardrails the team follows automatically, closing out the phase that made every other decision possible.
Branch Naming That Encodes Intent, Not Just Uniqueness
feature/<topic>, fix/<topic>, and chore/<topic> are the only three prefixes this project uses. The prefix alone tells a reviewer, before reading a single diff, whether to expect new behavior, a behavior correction, or non-behavioral maintenance — and it lets the pre-commit hook built later in this lesson apply slightly different checks by branch type if that is ever needed, without inventing a separate tagging system.
Conventional Commits, Scoped by Module
A Conventional Commit message — type(scope): description — already has a natural home for scope in this repository: the module a change touches. feat(organization): add slug validation and chore(pkg/config): extract Int helper are both immediately greppable; git log --oneline --grep='(organization)' finds every organization-service commit without needing path-based history search.
| Type | Meaning | Example in this repository |
|---|---|---|
| feat | New capability | feat(organization): add status transition rules |
| fix | Behavior correction | fix(pkg/config): treat empty string as unset, not as the literal value |
| chore | No behavior change | chore(organization): bump joho/godotenv to v1.5.1 |
| docs | Documentation only | docs(adr): record 0002 on the visitor service boundary |
A Pre-Commit Hook That Runs Only What Changed
Running make vet and make lint across all four modules on every commit — even a one-line docs change — would make every commit slow as more modules are added in later phases. The hook below inspects only the staged files, maps each changed path back to its owning module directory, and runs go vet and a gofumpt check against just those modules.
#!/usr/bin/env bash
set -euo pipefail
changed_files=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$changed_files" ]; then
exit 0
fi
modules=$(for f in $changed_files; do
dir=$(dirname "$f")
while [ "$dir" != "." ] && [ ! -f "$dir/go.mod" ]; do
dir=$(dirname "$dir")
done
[ -f "$dir/go.mod" ] && echo "$dir"
done | sort -u)
if [ -z "$modules" ]; then
exit 0
fi
status=0
for m in $modules; do
echo "==> checking $m"
unformatted=$(gofumpt -l "$m")
if [ -n "$unformatted" ]; then
echo "gofumpt found unformatted files in $m:"
echo "$unformatted"
status=1
fi
if ! go vet -C "$m" ./...; then
status=1
fi
done
exit $status
Protecting main Without Slowing Down Solo Work
For a small team, requiring a pull request for every single change to main — even a one-line README typo fix — is friction with no matching safety benefit. The convention this course follows instead: direct commits to main are acceptable only for chore and docs changes that touch no Go source; anything under feat or fix goes through a feature branch and, eventually, a pull request, once more than one contributor is active on the codebase.
Watching the Hook Actually Reject a Commit
==> checking pkg/apperr
gofumpt found unformatted files in pkg/apperr:
pkg/apperr/apperr.go
It exists for genuine emergencies (a broken hook blocking an urgent fix), not as a habit. A --no-verify commit should be rare enough that seeing one in git log is itself worth asking about in review.
Applied exercise
Extend the hook to reject commits touching more than one module at once
Conventional Commits' scope convention implies one logical change touches one module. Encode that as a second hook rule.
- Modify scripts/git-hooks/pre-commit to count the number of distinct modules in $modules.
- If more than one module is touched, print a warning listing them and exit non-zero, unless the commit message contains chore(multi): as an explicit escape hatch for genuinely cross-cutting changes (like this lesson's own Makefile edits, which touch the repository root, not a module — confirm the root case is excluded).
- Test it by staging changes in both pkg/apperr and pkg/logging at once and confirming the hook rejects the commit.
- Test the escape hatch by using a chore(multi): commit message for the same staged changes and confirming it now succeeds.
Deliverable
An updated pre-commit hook and a short transcript showing both the rejection and the escape-hatch success.
Completion checks
- A commit touching two modules without the escape-hatch phrase is rejected with a clear message naming both modules.
- The same staged changes succeed once the commit message includes chore(multi):.
Why does the pre-commit hook determine which modules were touched instead of always running make vet and make lint across the entire repository?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.