Branching Models for Teams
GitHub Flow, trunk-based development and Git Flow compared honestly, plus release and hotfix branches, the real cost of long-lived branches, and how to keep one current.
GitHub Flow, trunk-based development and Git Flow compared honestly, plus release and hotfix branches, the real cost of long-lived branches, and how to keep one current.
Two teams, same Git. One merges into main a dozen times a day
and has never cut a release branch. The other has a permanent
develop, a release/4.2 that only three people may touch, and
a written rule about hotfixes. Neither is doing Git wrong.
By the end of this lesson you will be able to read any team's branching model as a set of trade-offs — what it buys, what it costs, the cadence it assumes — and pick or change one on purpose rather than inherit it.
Git gives you commits, branches, and merges. That is the whole
vocabulary. It will let you commit straight to main, keep a
branch alive for a year, or delete a branch nobody reviewed — and
"never push to main" exists nowhere inside it. Rules like that
are a branching model: a convention a team agreed to and now
enforces with tooling, review, and habit.
A branch is one line in .git/refs/heads/ holding a commit ID, so
a model's cost is never technical — it is all human agreement.
Which means the question that decides your model is not "what is best practice." It is how does this software reach its users? Deploy from trunk several times a day and you need cheap integration. Ship 4.2 to customers who upgrade when they feel like it while you still owe 4.0 a security fix, and you need a model that holds three live versions.
This is the modern web default, the one to assume unless told
otherwise. main is always deployable; every change is a branch
off it, lives hours or days, and is deleted the moment it merges
back.
main A---B---------------M---N
\ /
feat/totals C---D---E---'It optimises for having one thing to reason about: one answer to "what is in production," one place a change can regress, one merge to revert.
It hurts twice. It has no answer for more than one released version — that is the release-branch pattern below — and it degrades silently, because nothing enforces "short-lived": a branch that quietly turns three weeks old is still nominally GitHub Flow while paying every cost of a long-lived one.
It also rests on a precondition people skip: "main is always
deployable" is a claim only your test suite can back. If you would
not ship a green build without a human reading the diff, main is
usually deployable, not always.
Trunk-based development takes the same shape and tightens the
clock. Branches live hours, not days, and everyone integrates into
main at least once a day. Unfinished work still ships to production,
unreachable behind a feature flag — a runtime conditional keyed
on configuration you can change without a deploy. The payoff is
integration: hours of divergence rarely collide, for reasons the
next section makes precise, and when production breaks the suspect
change is minutes old.
Flags are usually sold without a price tag, so here it is. A flag is a branch in your code that Git cannot see, and it is not deleted when it merges. Two flags mean four paths through that screen; ten mean nobody is testing the combination real users are in, and the losing side of each is dead code that still gets refactored by people who do not know it is dead.
Worse, adding a flag is somebody's ticket and removing one is nobody's, so flags outlive the experiment, then the feature, then the person who added them. Record an owner and a removal date when you create one, and schedule the cleanup.
Git Flow is the heavyweight: two permanent branches and three
temporary kinds. develop is the integration line, feature/*
branches off it, release/* is cut from develop once a version
is scoped and takes only stabilisation work, hotfix/* comes off
main for emergencies, and main holds nothing but tagged
releases.
Every release branch merges into main to be tagged and back into
develop so its fixes are not stranded; every hotfix does the
same. The point of that traffic is a stabilisation window: once
release/1.1 is cut, develop stays open for the next version
while a named group takes 1.1 to shippable with nobody's new work
landing underneath them.
The cost is proportionate: every change crosses at least two merges
before release, "what is live" gains two answers — the tag on
main and the tip of develop — and a frozen release branch piles
work onto develop, so the merge back is the largest your team
performs.
Who it is for: software you cut versions of and hand over — desktop and mobile apps, firmware, on-premises products, anything with a compliance sign-off. Its author added a note in 2020 saying it is the wrong model for software delivered continuously, which is the polite reply when someone calls it the professional default.
You do not have to adopt Git Flow to use its best idea. You need
the release branch the first time someone reports a bug in 2.3
while main has moved on to 2.5.
Branch from the tag, not from main: you want the code as it
shipped plus one fix, nothing else.
Merge the hotfix into release/2.3, tag the patch release, ship.
Then the step teams forget: get the fix onto trunk. A release
branch is a dead end, and a fix that lives only there is the same
bug again in 2.6.
Cherry-pick rather than merge, for a specific reason: a release
branch carries commits you do not want on trunk — a version bump, a
feature reverted to calm the release, a patch vendored for one
customer. Merging drags all of it along and ties a dead-end line of
history to main for good. Cherry-picking takes only the commit
you meant, as a new commit with a new ID; lesson 5 covers what it
means for conflicts and for -x.
When the fix is not urgent, reverse the order: land it on main
first, then cherry-pick onto the release branch. Trunk must never
lose a fix, so it should get it first.
Every live release branch is somewhere each security fix must be applied and tested, so how many you keep is a support commitment: decide a version's end-of-life date when you cut it.
Every model above is an argument about one number, and here is why
it matters. The cost of merging grows faster than the time you
spend apart: conflicts happen where two sets of edits overlap,
and both sets keep growing while you are away, so the chance of
collision tracks roughly the product of the two, not the sum. Two
days out, you conflict over an import list. Six weeks out, main
has been refactored underneath you — the function you edited is
renamed and split in two, and the API you call takes a context
object it did not have when you started.
That is not a Git problem.
Two designs, not two lines.
Git shows a text conflict and asks you to choose. The real decision is which of two incompatible ideas about the code survives — made by the branch author alone, under pressure to land.
The dangerous one.
Your code compiles, calls a function whose behaviour changed while you were away, and ships a bug no tool could have flagged.
Nothing asked you anything.
So measure divergence rather than guess at it:
Then pick a limit and say it out loud — "no branch goes a working day without trunk merged into it" — because a rule nobody stated is a rule nobody follows.
Branches have to catch up often. The two moves differ less in the result than in who else pays for them.
Merging is additive. Your commits keep their IDs and a merge commit
records the catch-up, so nothing a colleague already has is
invalidated. The cost is cosmetic but real: the log fills with
"Merge branch 'main' into feat/x" entries, and reading what the
branch changed means git diff main...HEAD rather than its
history.
Rebasing rewrites. It does not move your commits — it creates new
ones with the same changes and abandons the originals, which
survive only in the reflog until it expires (lesson 9). You get a
branch that looks like you started this morning, a straight line on
the current tip. You pay git push --force-with-lease and the fact
that every commit ID has changed.
The work differs too. A merge resolves each conflicted region once;
a rebase replays your commits one at a time, so the same region can
conflict once per commit. That is why a long rebase through a busy
area is a slog, and why rerere pays for itself here first.
Hence the rule, which is about people rather than history: rebase while the branch is yours alone, merge once others are reading it. "Yours alone" means nobody has pulled it and nothing is anchored to its commit IDs — a CI result or a review comment pinned to a SHA counts.
A branch name has one job: let someone reading the list in six months work out who owns it, what it is, and whether it is dead. The common shapes all do that.
Which one you choose matters far less than whether everyone uses
it, because consistency makes names machine-readable: your host can
protect release/* and require checks on feat/*, and CI can run
the fast suite on one prefix and the full one on another. Write the
scheme in the README and stop debating it.
One trap with slashes: Git stores refs as paths, so feat and
feat/totals cannot coexist — the second fails with "cannot
create," because refs/heads/feat would have to be both a file and
a directory. Pick prefixes you will never also use as whole names.
Hygiene is the other half: dead branches accumulate until nobody can find a live one.
Run that last one on a repository you have inherited: the bottom of the list is an archaeology of abandoned work, the top is where the team actually is.
The default.
One trunk, short-lived branches, with an explicit limit on branch age. Right for anything you deploy rather than ship.
Only if you will schedule flag removal.
Feature flags are debt with a due date. A team that adds them and never removes them has swapped merge pain for a combinatorial explosion of untested code paths.
Only if users install versions.
It was built for software with supported releases you must patch. Adopting it for a web app buys you five branch types and no benefit.
The habits, whichever model you land on: branches that die within days, a releasable trunk, and release fixes that make it home.
Next, in lesson 7, is pull requests and code review: the three merge buttons and what each leaves in your history, how to update a branch without detaching the comments on it, and the host-side rules that turn the convention you chose here into something enforced.
Before then, do some archaeology. Run the for-each-ref command
above on a repository you work in and look at everything older than
a fortnight, then watch the rev-list divergence count on your own
branch for a week. That number climbing teaches this faster than
any diagram.
main A--B--C--D--E--F--G--H--I merges land all day
\_/ \_/ \_/ \_/ branches are hours old,
unfinished screens ship
dark behind flagsmain A-------------T1-----------T2 (tagged releases only)
\ / /
release/1.1 \ R1---' /
\ / /
develop D1--D2--D3---D4---D5---D6
\ / \ /
feature F1-' F2-'main A---B---C---D---E---F---H' H' = same fix, new commit
\ ^
\ | cherry-pick
\ |
release/2.3 o---------------H---' tagged v2.3.1
(branched at tag v2.3.0)feat/checkout-totals type prefix + short description
fix/1284-token-expiry type prefix + ticket + description
ana/feat/checkout-totals author prefix, common on large teams
release/2.3 hotfix/2.3.1 reserved prefixes your tooling matchesgit switch -c release/2.3 v2.3.0 # branch off the released tag
git switch -c hotfix/token-expiry # do the work on its own branchgit switch main
git cherry-pick <sha-of-the-fix> # replay the one commitgit fetch origin
git rev-list --left-right --count origin/main...HEAD
# prints two numbers: commits only on main, commits only on yours
git diff --stat origin/main...HEAD # how big your branch has gotgit fetch origin
git merge origin/main # bring main's commits into your branch
# or
git rebase origin/main # replay your commits on top of maingit branch --merged main # fully merged, safe to delete
git branch -d feat/checkout-totals # delete a merged branch
git fetch --prune # drop refs deleted on the host
git for-each-ref --sort=-committerdate refs/remotes \
--format='%(committerdate:short) %(refname:short) %(authorname)'git switch -c feat/checkout-totals # branch off the current trunk
git switch -c release/2.3 v2.3.0 # release branch from a tag
git switch -c hotfix/token-expiry # the fix, on its own branch
git fetch origin # refresh remote-tracking refs
git rev-list --left-right --count origin/main...HEAD # behind/ahead
git diff --stat origin/main...HEAD # how big the branch has got
git merge origin/main # catch up (others read it)
git rebase origin/main # catch up (branch is yours)
git push --force-with-lease # push after a rebase, safely
git cherry-pick <sha> # port one fix to trunk
git cherry -v main release/2.3 # fixes not yet ported to main
git log -S "flags.newCheckout" # the whole life of a flag
git branch --merged main # branches safe to delete
git branch -d feat/checkout-totals # delete a merged branch
git fetch --prune # drop refs deleted on the host
git for-each-ref --sort=-committerdate refs/remotes # stale branches
git config --global rerere.enabled true # reuse conflict fixes