GitGroundworkGit
11 / 18 written chapters

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.

9 min read3 diagrams9 sections
09On this page
  1. The one idea underneath all of them
  2. Trunk-based development
  3. GitHub flow
  4. GitFlow
  5. Side by side
  6. Release branches done well
  7. Feature flags versus long-lived branches
  8. CI gating: keeping main green
  9. Monorepo considerations
Fresher Mid Senior

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.

In one minute

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.

branches live hours fix main release/3.1 v3.1.0 v3.1.1 cherry-pick -x
Everything merges into main within a day. When a release needs support, it is cut from main, and fixes land on main first and are then copied to the release.

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.

feature/cart fix/login feature/search PR #88 → deploy PR #91 → deploy PR #95 → deploy main
Each merge to main is a release candidate, and usually a release. There is no separate integration branch and no release branch.

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.

main hotfix/* release/* develop feature/* v1.0 v1.0.1 v1.1 Main only ever receives release and hotfix merges. Every release and hotfix is merged twice.
Five kinds of branch and two merges per release. The structure is the point: it keeps unreleased work away from main entirely.

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-basedGitHub flowGitFlow
Long-lived branchesmain (plus release branches if needed)mainmain, develop
Typical branch lifeHoursDaysDays to weeks
Unfinished workMerged, behind flagsStays on the branchStays on the branch, then on develop
ReleasesFrom main, often continuousEvery mergeScheduled, via release branches
Supports old versionsWith release branchesNot by itselfYes, by design
Merge painLowestLowHighest
NeedsFast CI, flags, disciplineGood CI, quick reviewRelease management
FitsHigh-velocity product teams, large monoreposMost web apps and servicesVersioned, 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 branchFeature flag
Merge conflictsGrow every day the branch livesSmall and continuous
Deploy vs releaseTied: merging is releasingSeparate: deploy any time, turn on when ready
RolloutAll users at onceBy percentage, region, account or employees first
RollbackRevert and redeployFlip the flag off in seconds
CostPainful merges, late integration bugsMore 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_request workflows 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 CODEOWNERS file routes apps/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, and git sparse-checkout set apps/web packages/ui writes 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.
A sensible default

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.