Branching strategies
Trunk-based, release branches, or GitFlow — choose based on your actual shipping cadence, with warning signs you've outgrown the model.
A team of six engineers ships from main every day. Two years later they're
fifty, GitFlow is on the wiki, and a release takes a week of merge conflicts
and "wait, was that fix in the last cut?". Nobody decided to make it harder.
The branching model just stayed the same while everything around it changed.
Picking a branching strategy is not picking a favourite. It's picking which problems you're willing to have — because every model trades one set of pains for another.
How often can you ship? The question that picks your branching model
Forget the diagrams. Every branching model is a different answer to one
question: how often can a change in main reach a real user?
| Strategy | Answer | Implied cost |
|---|---|---|
| Trunk-based | Today, multiple times | Investment in CI, tests, and feature flags |
| Release branches | Every sprint or fixed cadence | Coordination overhead per cut |
| GitFlow | When develop is "ready" | Two long-lived branches, manual integration |
Pick the model that matches how often you actually ship — not how often you'd like to.
If you ship every two weeks but pretend you ship daily, trunk-based will feel chaotic. If you ship daily but pretend you ship monthly, GitFlow will feel like wading through cement.
Trunk-based: the model with the fewest surprises
One long-lived branch (main). Short-lived feature branches that merge within
hours or a day. Every merge to main is potentially deployable.
It works when:
- CI is fast enough that nobody bypasses it (under ~10 minutes end-to-end)
- Tests catch enough regressions that a green build is a real signal
- You can ship unfinished work safely — usually with feature flags
It does not work when:
- A flaky CI means people sit on changes until the queue clears
- You ship to an environment where rollback is hard (mobile, embedded, on-prem)
- Your test suite gives green for code that is visibly broken
The honest version: trunk-based without feature flags is just "fast and fragile." If you can't afford the flag infrastructure yet, release branches are not a regression — they're an honest signal of what your tooling supports.
Release branches: the model between trunk-based and GitFlow
main accumulates merged work. On a cadence (every sprint, every Friday, every
month), you cut a branch from main — release/2024-11 — stabilise it with
fixes, ship it, and tag.
The win: a stable surface to test and deploy from, separate from ongoing work. The cost: cherry-picking. Every fix that needs to go to the release branch is a manual decision and a chance to forget.
This model fits teams that:
- Ship to environments with downtime cost (data pipelines, batch systems)
- Have a QA step that needs a stable target
- Cannot afford the feature-flag investment trunk-based assumes
Cherry-picking is where this model breaks down. If you cherry-pick more than two or three fixes per release, you have a different problem — either your tests don't catch regressions, or you're cutting too early. Don't normalise the cherry-pick treadmill.
GitFlow: the model with the highest ceiling, the highest floor
Two long-lived branches (main and develop), plus feature/*, release/*,
and hotfix/*. Vincent Driessen wrote it in 2010 for software with explicit
versioned releases — desktop apps, libraries, on-prem products.
It justifies its cost when:
- You ship discrete versions that users install (not continuously deployed services)
- Multiple versions live in production simultaneously and need separate hotfixes
- A regulated environment requires explicit "this is what we shipped" branches
It is a tax on everything else:
- Two long-lived branches means twice the merge surface
- Every feature crosses three branches before reaching production
- Newcomers spend a week learning the dance before their first PR lands
Driessen himself added a note to the original post in 2020: "if you are building software that is explicitly versioned, GitFlow is great. If you are running a web app, you almost certainly don't want GitFlow." Use it on purpose, not by default.
Warning signs you've outgrown your model
From trunk-based
- Feature flags outnumber features. You wrote the flag, the feature, the flag-removal, and the cleanup. The flag is still there.
mainis read-only on release day. If shipping requires a freeze, your "always deployable" claim is fiction.- A hotfix needs three reviewers and a meeting. The model assumes you can fix forward fast. If you can't, you're paying the cost without the benefit.
From release branches
- You're cherry-picking more than three fixes per release. Stabilisation has become a parallel branch of work.
- Two release branches exist at once. You started overlapping releases to keep up with the schedule, and the cost is doubling.
- Nobody remembers which fix is in which release. Track them, or move to trunk-based.
From GitFlow
developandmaindiverge for weeks at a time. You're maintaining two parallel realities and the merge tomainis now its own project.- Hotfixes skip
develop. Then someone re-introduces the bug on the next feature merge. The model promised this wouldn't happen. - You can't explain GitFlow to a new hire in 10 minutes. Onboarding cost is part of the model's cost.
Choosing the right git branching strategy for your team
Pick on purpose, not by inheritance. Re-pick when the team size, the shipping cadence, or the tooling changes — not when somebody on the team finds a new blog post.
The right model is the one whose costs you can pay this quarter, with the tooling you actually have. The wrong model is the one you picked two years ago and never revisited.
Format
| Part | Required | Description |
|---|---|---|
type/ | Yes | Change type prefix |
scope- | No | Affected module (conventional commits) |
123- | No | Issue number |
description | Yes | Text in kebab-case, max 50 characters |
Branch Types
| Prefix | Purpose |
|---|---|
feat/ | New functionality |
fix/ | Bug fix |
hotfix/ | Urgent fix from main or a release branch |
release/ | Stabilisation cut before shipping |
chore/ | Config, CI, dependencies, tooling |
docs/ | Documentation or content |
refactor/ | Restructuring without behaviour change |
test/ | Tests with no production code change |
Scope — conventional commits
The scope identifies the affected module. It goes in parentheses on the commit associated with the branch: type(scope): description.
| Branch | Corresponding commit |
|---|---|
feat/auth-add-oauth | feat(auth): add OAuth login |
fix/api-null-response | fix(api): handle null user response |
chore/ci-upgrade-node | chore(ci): upgrade to Node 20 |
refactor/db-extract-queries | refactor(db): extract query layer |
Scope is optional but recommended in projects with more than one clearly defined area of responsibility.
Rules
- Lowercase only
- Hyphens as separator — no underscores, no camelCase
- Max 50 characters after the slash
- No personal names in the description
- No trailing dots or slashes
Long-Lived Branches
| Branch | Purpose |
|---|---|
main | Always deployable (all models) |
develop | GitFlow only — accumulates features before release |
Valid Examples
feat/auth-add-oauth-loginfix/api-null-response-handlerhotfix/checkout-null-pointerrelease/2024-11chore/ci-update-node-20docs/contributing-guiderefactor/db-extract-query-layertest/auth-integration-coverage