Branching Strategies & Trunk-Based Flow
Trunk-based flow keeps main always releasable while feature branches stay short.
Go teams pair that model with CI that runs go test ./..., go vet, and go mod tidy checks on every push.
Summary
Most Go libraries and services ship from a single integration branch.
Developers branch for hours or days, merge through pull requests, and tag semver releases from the same line.
Long-lived release branches are reserved for supported major versions, not everyday feature work.
That rhythm matches how module proxies pick versions from tags on the default branch.
Recipe
Quick-reference recipe card - copy-paste ready.
git switch main && git pull --ff-only
git switch -c feat/short-lived-change
# edit, test locally
go test ./...
go mod tidy && git diff --exit-code go.mod go.sum
git push -u origin feat/short-lived-change
# open PR, merge when CI green, delete branchWhen to reach for this:
- Standardizing team workflow for a Go module repo.
- Reducing
go.summerge pain from stale branches. - Preparing to tag
v1.xreleases frommainwithout a GitFlow release branch. - Onboarding teams used to month-long feature branches.
Working Example
A library team ships metrics API improvements:
# Day 1
git switch -c feat/metrics-api
# implement + tests
go test ./...
git push -u origin feat/metrics-api
# PR opened, CI runs on ubuntu with Go 1.26.x
# Day 2 - rebase onto fresh main
git fetch origin
git rebase origin/main
go test ./...
git push --force-with-lease
# After approval
# squash-merge or merge commit per team policy
git switch main && git pull
git tag -a v1.5.0 -m "v1.5.0"
git push origin v1.5.0What this demonstrates:
- Branch lifetime measured in days, not sprints.
- CI validates the module graph before merge.
- Release tag cut from the same trunk commit consumers already tested via CI.
Deep Dive
How It Works
main(trunk) holds the canonical module state.- Contributors branch, push, and open PRs.
- Required checks run
go test, lint, and oftengo mod tidydrift detection. - Merge updates trunk; feature branch is deleted.
- Maintainers tag semver releases from trunk when ready.
Trunk-based does not mean "commit directly to main without review."
It means integrate frequently and avoid parallel long-lived lines that diverge in dependencies.
Branch Types
| Branch | Lifetime | Purpose |
|---|---|---|
main | Permanent | Integration + release source |
feat/*, fix/* | Hours to few days | Individual changes |
release/v1 (optional) | Months to years | Patch-only backports for old major |
hotfix/* | Hours | Urgent patch, may cherry-pick to release branch |
Go CI Gates
| Check | Why it matters |
|---|---|
go test ./... | Catches broken packages before merge |
go vet ./... | Static checks for common mistakes |
go mod tidy + diff | Prevents surprise dependency drift on trunk |
golangci-lint | Team style and bug patterns |
| Race detector (select jobs) | Concurrency regressions |
Merge vs Rebase Policy
Squash-merge keeps trunk history one commit per PR.
Rebase-merge preserves individual commits when they are already well formed.
Rebase feature branches onto main before merge to surface conflicts early, especially in go.sum.
Gotchas
- Week-long feature branches -
go.sumand generated files conflict often. Fix: slice work into smaller PRs or mergemaindaily. - Tagging from a stale release branch - Consumers on trunk miss fixes. Fix: tag patch releases from the branch that actually tracks supported lines.
- Skipping CI on "docs only" PRs that touch
go.mod- Dependency changes still need full test. Fix: path filters exclude only true doc paths. - Force-push to shared branches - Breaks open PRs and pseudo-version SHAs. Fix: restrict force-push to personal feature branches; use
--force-with-lease. - Multiple modules, one tag convention - Ambiguous which module a tag versions. Fix: document per-module tag prefixes or separate release workflows.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| GitFlow (develop + release) | Heavy staged QA with parallel versions | Small Go library with continuous trunk releases |
| Release branches per major | Long-term v1 patches while v2 evolves on trunk | Team lacks cherry-pick discipline |
| Stacked PRs / graphite flow | Large change split for review | CI cannot run partial graphs cheaply |
| Direct commits to main | Solo maintainer, low risk | Team repo without required checks |
FAQs
How short should feature branches be?
Aim for one reviewable PR, usually under 400 lines of Go change.
If longer, split by package or behavior behind feature flags.
Should we use squash merge?
Squash keeps trunk readable and maps one PR to one changelog entry.
Preserve multi-commit history when commits are already atomic and messages are curated.
Do release branches conflict with modules?
They do not, if tags on each branch follow semver and module paths stay consistent.
Document which major line each release branch maintains.
What protected branch rules matter for Go?
Require PR reviews, disallow force-push on main, and require status checks for test + tidy.
Optional: require signed commits or signed tags for releases.
How handle go.sum merge conflicts?
Accept one side, run go mod tidy, re-run tests, commit the tidy result.
Never hand-edit checksum lines without understanding the dependency change.
Can trunk-based work for monorepos?
Yes, with per-module CI paths and clear tagging docs.
go.work local dev does not replace trunk integration policy.
When is GitFlow still reasonable?
When release certification takes weeks and multiple versions receive patches in parallel.
Even then, keep main close to releasable to reduce merge cost.
Should hotfixes branch from tags?
Branch from the release commit or supported release branch, cherry-pick to trunk, tag patch semver.
Verify go test ./... on both lines when dual maintenance is active.
How do feature flags relate to branching?
Flags let trunk integrate incomplete work without exposing behavior.
Go uses build tags or runtime config; branch lifetime can still stay short.
What about default branch renames?
Update module README, CI triggers, and branch protection after master to main renames.
Module proxies follow tags on the current default branch.
Does trunk-based forbid forks?
Contributors still use forks and PRs.
Maintainers integrate to the canonical trunk quickly after review.
How align versioning with trunk merges?
Tag after merge when release notes are ready.
Do not tag mid-PR; consumers need commits that passed full CI on trunk.
Related
- Git for Go Teams - Git as the module release channel
- Git Basics for Go Projects - Clone, branch, commit, tag basics
- GitHub Actions for Go Repos - CI workflows enforcing gates
- Rebasing, Cherry-Pick & Worktrees - History tools for short branches
- Code Review with GitHub PRs - PR norms that keep trunk healthy
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).