G11 · Part 2 · Working together
Team workflows
Trunk-based, GitHub flow and GitFlow compared, and how release branches, feature flags and CI gates fit around them.
09On this page
A branching strategy is a team agreement about three things: where work happens, how it gets back to the main line, and what gets released. Git doesn't care which one you pick. Your merge conflicts, your release process and your CI bill do. You should be able to name the strategy your team uses and defend the trade-off, because that question comes up in almost every senior interview.
On your first team, ask one question: "where do I branch from,
and where does my pull request go?" Most teams answer "from
main, back into main". Keep your branch
small, open the PR within a day or two, and pull in the latest
main often. The names below (trunk-based, GitHub
flow, GitFlow) are different answers to how long work may stay
on a branch and how releases are cut.
The one idea underneath all of them
The cost of integrating two lines of work grows with how far they have drifted apart. A branch that lives for an afternoon merges cleanly because almost nothing else changed in that time. A branch that lives for six weeks collides with six weeks of everyone else's changes, and the conflicts are not only textual: code that merges cleanly can still break, because someone renamed a function you call. Every strategy below is a different answer to the question "how long do we let work stay apart, and what do we do about it?"
Trunk-based development
Everyone integrates into one branch, the trunk (usually
main), at least once a day. Branches, if you use them at
all, live for hours and go through a quick PR. Work that isn't
finished still merges, hidden behind a feature flag. Main must always
build and pass its tests, because everyone is building on it.
It needs three things to work: a fast, trustworthy test suite, a feature-flag system, and code review that keeps up with small PRs. Without them, trunk-based development ships half-built features or breaks main several times a day. With them, it has the lowest merge cost of any strategy, and it is what very large engineering organisations converge on.
GitHub flow
One long-lived branch, main, which is always deployable.
For each change: branch from main, commit, open a pull request, get
review and green CI, merge, deploy. That is the whole process. Branches
usually live for a few days rather than a few hours, which is the main
difference from trunk-based development in practice.
It fits web apps and services with continuous deployment, where there is only ever one version in production. Its weak point is the same: there is no built-in place to maintain version 2 while version 3 is being built. If you need that, you add release branches, and at that point you are closer to trunk-based development with releases.
GitFlow
GitFlow, published by Vincent Driessen in 2010, uses two permanent
branches and three kinds of temporary ones. main holds
only released code, one merge per release, each tagged.
develop is where features are integrated. Features branch
from develop and merge back into it. When enough is ready, a
release/* branch is cut from develop for stabilisation,
then merged into main (and tagged) and back into develop. Urgent fixes
branch from main as hotfix/* and are merged into both.
GitFlow fits software that ships in numbered versions on a schedule and
has several versions in the field at once: desktop apps, mobile apps
waiting on store review, on-premise installs, libraries. For a web app
that deploys every day it is mostly ceremony. Every change is merged
at least twice, develop drifts from main,
and the delay between writing code and running it in production grows.
Driessen added a note to the original article in 2020 saying as much:
for continuously delivered software, adopt something simpler.
Side by side
| Trunk-based | GitHub flow | GitFlow | |
|---|---|---|---|
| Long-lived branches | main (plus release branches if needed) | main | main, develop |
| Typical branch life | Hours | Days | Days to weeks |
| Unfinished work | Merged, behind flags | Stays on the branch | Stays on the branch, then on develop |
| Releases | From main, often continuous | Every merge | Scheduled, via release branches |
| Supports old versions | With release branches | Not by itself | Yes, by design |
| Merge pain | Lowest | Low | Highest |
| Needs | Fast CI, flags, discipline | Good CI, quick review | Release management |
| Fits | High-velocity product teams, large monorepos | Most web apps and services | Versioned, installed software |
Open source adds one more shape on top of any of these: the fork and pull request model, where contributors can't push to the main repository at all. They push to their own copy and open a PR upstream. That is covered in Pull requests and GitHub.
Release branches done well
A release branch exists to stabilise and then maintain one version while main moves on. It is cut from main (or develop), named for the version, and from then on receives only fixes.
$ git switch -c release/3.1 main
$ git push -u origin release/3.1
$ git tag -a v3.1.0 -m "Release 3.1.0"
$ git push origin v3.1.0
$ git switch release/3.1
$ git cherry-pick -x 7b3e9f2
$ git tag -a v3.1.1 -m "Release 3.1.1: tax rounding fix"
$ git push origin release/3.1 v3.1.1
The rule that keeps release branches sane is upstream
first: fix the bug on main, then cherry-pick the fix to each
release branch that needs it. If you fix it on the release branch
instead, you have to remember to bring it back, and the day someone
forgets, the bug comes back in the next version. Use annotated tags
(-a) for releases, because they record who tagged, when,
and a message, and git describe uses them. Decide up
front how many versions you support, and delete the branch (the tags
remain) when a version reaches end of life.
Feature flags versus long-lived branches
A long-lived feature branch keeps unfinished work away from users by keeping it away from main. A feature flag does the same by keeping it switched off at runtime while the code is merged.
if (flags.isEnabled("coupon-stacking", { userId: user.id })) {
return applyStackedCoupons(cart, codes);
}
return applySingleCoupon(cart, codes[0]);
| Long-lived branch | Feature flag | |
|---|---|---|
| Merge conflicts | Grow every day the branch lives | Small and continuous |
| Deploy vs release | Tied: merging is releasing | Separate: deploy any time, turn on when ready |
| Rollout | All users at once | By percentage, region, account or employees first |
| Rollback | Revert and redeploy | Flip the flag off in seconds |
| Cost | Painful merges, late integration bugs | More code paths to test; flags must be removed after launch |
Flags have their own debt. Every flag doubles the number of states the
code can be in, and a flag nobody removes is a permanent
if with an untested branch. Give each flag an owner and
an expiry date, and delete it in the sprint after full rollout. For
large refactors that can't hide behind one if, use
branch by abstraction: put an interface in front of the old
implementation, build the new one behind the same interface on main,
switch over, then delete the old one.
CI gating: keeping main green
Every strategy assumes main works. CI is how you make that true instead of hoped for. The layers are:
- Pre-merge checks. Build, lint, type-check and tests run on every PR, and branch protection makes them required. On GitHub,
pull_requestworkflows run against a test merge of the PR into its base, not the branch alone, so you are testing what main would become. - Up to date, or a merge queue. Two PRs can each pass against today's main and still break it together: one renames a function, the other adds a new call to the old name. No text conflict, so Git merges both happily. Requiring branches to be up to date closes the gap but forces endless rebasing on a busy repo. A merge queue closes it properly by testing each PR on top of the ones ahead of it.
- Post-merge checks. Slower suites (end-to-end, performance) run on main after merge, and a failure pages the team.
- A revert-first policy. When main breaks, the default response is to revert the offending PR, not to push a fix forward under pressure. Fix it calmly on a branch and merge again.
- Flaky tests are bugs. A test that fails one time in twenty teaches everyone to click "re-run", and then real failures get re-run too. Quarantine and fix them.
Monorepo considerations
A monorepo puts many projects in one repository. The big win is atomic change: you can update a shared library and every caller in one PR, and there is never a version mismatch between them. The costs show up in Git and CI.
- CI must only build what changed. Running every test for a README edit doesn't scale. Tools such as Nx, Turborepo, Bazel and Pants compute which packages a diff affects, for example
turbo run test --filter=...[origin/main]. Path filters in the workflow are the crude version. - Ownership needs to be per folder. A
CODEOWNERSfile routesapps/billing/changes to the billing team and requires their review. - PR volume makes a merge queue close to mandatory. With dozens of merges an hour, "up to date with main" is out of date by the time CI finishes.
- Clones get big. Partial clone (
git clone --filter=blob:none) skips downloading old file contents until needed, andgit sparse-checkout set apps/web packages/uiwrites only the folders you work on. Scale and edge cases covers both. - History gets noisy. Scope your questions to a path:
git log --oneline -- apps/web.
For most teams building a web product: GitHub flow with small PRs, squash merges, required checks and one required review, feature flags for anything that takes more than a few days, and a merge queue once the team is big enough that PRs start breaking each other. Add release branches only when you truly support more than one version.
Finished reading?
0 of 18 chapters read in this series