git-flow vs trunk-based
Two branching models: heavyweight git-flow and trunk-based with feature flags. When to use which, and why.
Why a branching model
Branches in git are cheap, but without an agreement a team quickly drowns in chaos. Which branches live long, where do tasks merge, where does a release ship from, where does a hotfix go? A branching model is a team agreement that answers these questions.
There are many models, but the whole spectrum makes sense once you know the two extremes: git-flow and trunk-based. In between sits the simpler GitHub flow, with a few words on it below.
git-flow: two permanent branches and many temporary ones
Vincent Driessen described the model in 2010. It has two permanent branches:
mainholds what's in production. Every commit on it is a release, and it carries a version tag;developcollects finished work for the next release.
The original article calls the production branch master, these days it's usually called main. The
other branches are temporary and get deleted after merging:
feature/*for a task. Branches off fromdevelopand merges back into it;release/*for preparing a release. Branches off fromdevelop, where the version number and small bugs get fixed. The finished branch merges intomain, gets tagged, and merges back intodevelopso the fixes aren't lost;hotfix/*for an urgent production fix. Branches off frommain, because you need to fix exactly what's currently running for users. The finished fix merges intomainwith a new tag and back intodevelop. If arelease/*branch is open at that moment, the fix merges into it instead ofdevelop.
Here's what shipping release 1.3.0 looks like:
git checkout -b release/1.3.0 develop
git checkout main
git merge --no-ff release/1.3.0
git tag -a v1.3.0 -m "release 1.3.0"
git checkout develop
git merge --no-ff release/1.3.0
git branch -d release/1.3.0
The --no-ff flag forces git to create a merge commit even when the branch could just be fast-forwarded.
That way the history shows which commits came in as one branch. git-flow uses --no-ff for every merge.
The scheme is detailed and fits well with infrequent, versioned releases: desktop programs, libraries, products that support several versions at once. In 2020 the author himself added a note to the article: for continuously delivered web applications he recommends a simpler model, such as GitHub flow.
Why long-lived branches hurt
While a branch lives on its own, main keeps moving forward. The longer the gap, the more files have
changed on both sides, and the heavier the merge. With several such branches, merging turns into days of
separate work. That's what people call merge hell.
How to tell a branch has gone stale. The point where a branch split off from main is shown by
merge-base:
git merge-base main feature/payments
42ea92042ed03244d20fc206c404fd6e17287ef6
You can look up that commit's date with git log -1 <hash>. The date of the branch's latest commit
doesn't help here: a month-old branch could have gotten a commit yesterday.
The A..B notation means "commits that are in B and not in A". That gives two questions:
git log --oneline main..feature/payments
4e3452f pay
7d51986 pool 20
That's the branch's own work. And how far main has moved since the branch point:
git rev-list --count feature/payments..main
2
The easiest way to check for a conflict is a trial merge that you abort right away:
git merge feature/payments
Auto-merging app.conf
CONFLICT (content): Merge conflict in app.conf
Automatic merge failed; fix conflicts and then commit the result.
git merge --abort
git merge --abort puts the branch and the working folder back to how they were before the merge. If
your Git is set to another language, the messages will be translated, the file name stays the same.
trunk-based: one main branch
trunk-based flips the approach. There's one main branch (main, also called the trunk), and everyone
merges small changes into it often. The team doesn't keep any other long-lived branches.
Task branches exist, but they're short. They're opened for review and a CI run, and trunkbaseddevelopment.com recommends keeping such a branch to no more than a couple of days. Once merged, the branch gets deleted, including on the server:
git push origin --delete fix/typo
To ../origin.git
- [deleted] fix/typo
short-lived branch (a day or two) → review and CI → merge into main → deploy
Branches are short, so they barely drift from main and don't produce merge hell. main is always in a
working state, and you can ship from it at any moment. This is the model for teams with CI/CD and
frequent deploys.
A release in trunk-based is a tag on a main commit. Some teams cut a release/* branch off main
before a release, but don't develop on it. A bug gets fixed in main first, and the finished commit gets
carried into the release branch with git cherry-pick.
Unfinished features and feature flags
The main question for trunk-based: if everything merges into main quickly, what do you do with a
feature that isn't ready yet? The answer is a feature flag: a switch that turns a piece of code on
or off without a new release. The code merges into main, but it's hidden behind the flag and turned
off.
Pseudocode:
if (featureEnabled("new-checkout")) {
newCheckout();
} else {
oldCheckout();
}
Where to store the flag is up to the team: a config file, an environment variable, a dedicated flag service. In the simplest case it's a line in a config:
new_checkout=off
The feature ships to production turned off. It gets turned on separately once it's ready. That splits
two events: "ship the code" and "turn on the feature". Unfinished work doesn't sit in a separate
long-lived branch and doesn't get cut out of main before a release, it just stays off.
Flags have a cost. Every flag adds a branch in the code, and forgotten flags pile up over years. Once a feature is on for everyone and working, the flag and the old code path get deleted. trunkbaseddevelopment.com recommends setting a review date about a month after the release.
GitHub flow: the middle ground
GitHub flow is simpler than git-flow: one permanent branch main, a branch per task, a pull request
with review, a merge into main, and the branch gets deleted. There's no separate develop,
release/*, or hotfix/*. If branches live a day or two, it's close to trunk-based. If they live for
weeks, the problems of long-lived branches come back.
What to pick
- Infrequent, versioned releases, several supported versions at once, you need release and hotfix branches: git-flow.
- Frequent deploys, CI/CD, one production environment: trunk-based with short-lived branches and feature flags.
Web and SaaS products usually go trunk-based: changes land faster, there are fewer conflicts. git-flow stays relevant where a release is an explicit version that's prepared and shipped on its own.
Common mistakes
- A hotfix from
develop.developholds work that hasn't shipped yet. A fix started from it will drag that work into production. A hotfix always starts frommain, meaning from what's running for users. - The hotfix didn't get merged back. Production is fixed, but the bug is still in
develop. The next release out ofdevelopwill bring it back. - A long-lived branch in trunk-based. A branch that lives for weeks is git-flow without the rules. A big task gets cut into small pieces and merged one at a time behind a flag.
- Unfinished work turned on. A flag got turned on temporarily and forgotten. Before a release, check
what's turned on in
main. - Unfinished work cut out. A commit with a feature got reverted so it wouldn't land in the release. Later it has to come back and get merged again. Turning off a flag is easier.
- A merged branch didn't get deleted. A month later nobody remembers whether it was merged or not.
git branch -donly deletes a merged branch, and complainsnot fully mergedabout one that isn't, which is a handy check.
What you'll need for the tasks
git checkout -b <branch> <from where>: a branch from the point you need, not from the current one;git merge --no-ff <branch>,git merge --abort;git merge-base, theA..Bnotation ingit logandgit rev-list --count;git log --oneline --graph --decorate --all: see every branch at once;git branch -d,git push origin --delete <branch>;- from earlier lessons: picking a version number by SemVer,
git tag -a,git push origin <tag>,git ls-remote --tags origin.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson git-workflow-03-flow-vs-trunk
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress