Gitflow Workflow
Gitflow is a branching model for Git repositories, first described by Vincent Driessen in 2010, that gives every branch a specific job and defines strict rules for how branches merge into one another. Instead of everyone committing to a single line of history, Gitflow separates in-progress feature work, release stabilization, and emergency production fixes into distinct branch types, each with its own lifecycle. It became especially popular for projects with scheduled, versioned releases, where teams need an always-shippable branch kept separate from ongoing development. This lesson covers the full model, the commands for it (both the optional git-flow helper extension and plain Git), and when Gitflow is — and isn’t — the right choice.
Overview / How it works
Gitflow defines two long-lived branches and three (or four) types of short-lived, purpose-built branches.
| Branch | Lifespan | Branches off of | Merges into | Purpose |
|---|---|---|---|---|
main |
Permanent | — | — | Always reflects production. Every commit here is tagged with a version. |
develop |
Permanent | main (once, at project start) |
— | Integration branch where finished features accumulate between releases. |
feature/* |
Temporary | develop |
develop |
One unit of new work, e.g. feature/login-page. |
release/* |
Temporary | develop |
main and develop |
Stabilize a set of features for shipping: version bumps, last-minute bug fixes, docs. |
hotfix/* |
Temporary | main |
main and develop |
Emergency fix to production without pulling in unreleased develop work. |
The rules matter as much as the branch names. A feature branch never merges into main and never talks to a hotfix branch directly — it only ever merges back into develop. A release branch is where you freeze scope: no new features land there, only fixes needed to ship. When it’s ready, it merges into main (which gets tagged, e.g. v1.2.0) and also back into develop, so any stabilization fixes made during the release aren’t lost for the next cycle. A hotfix branches directly off main — bypassing develop, which may contain half-finished features you don’t want in production — fixes the emergency, then merges into both main and develop (and into any release branch currently in progress) so the fix doesn’t regress in the next release.
What’s actually happening in the object database
Remember that a Git branch is nothing more than a movable pointer (a 41-byte reference file) to a commit — creating feature/login-page just writes a new pointer at your current commit; it doesn’t copy any files. A commit object records a snapshot (a tree of blobs) plus a pointer to its parent commit(s). When Gitflow merges a branch back with git merge --no-ff, Git creates a new commit with two parents — the tip of develop and the tip of the feature branch — even if a fast-forward were possible. That explicit merge commit is what makes Gitflow’s history readable: git log --graph shows exactly which commits belonged to which feature, release, or hotfix, because the merge commit’s second parent preserves the branch structure. Without --no-ff, Git would simply slide the develop pointer forward onto the feature branch’s tip (a fast-forward) — the commits land in the same place, but the grouping information is gone from the graph.
The git-flow command you’ll see below (also called git-flow-avh, the actively maintained fork) is not a Git core feature — it’s a shell script wrapper that automates exactly the branch-naming and merge sequence described above. Everything it does can be reproduced with plain git switch, git merge --no-ff, and git tag, which this lesson also shows, so you can follow Gitflow even on a machine where the extension isn’t installed.
Syntax
If you install the extension, the general form is:
git flow <branch-type> <action> [name]
# branch-type: feature | release | hotfix | support
# action: start | finish | publish
git flow command |
Plain Git equivalent |
|---|---|
git flow init |
Manually create and push develop from main |
git flow feature start name |
git switch develop && git switch -c feature/name |
git flow feature finish name |
git switch develop && git merge --no-ff feature/name && git branch -d feature/name |
git flow release start 1.2.0 |
git switch develop && git switch -c release/1.2.0 |
git flow release finish 1.2.0 |
Merge into main, tag, merge into develop, delete branch |
git flow hotfix start 1.2.1 |
git switch main && git switch -c hotfix/1.2.1 |
git flow hotfix finish 1.2.1 |
Merge into main, tag, merge into develop, delete branch |
Install the extension if you want the shorter form:
# Debian/Ubuntu
sudo apt-get install git-flow
# macOS (Homebrew)
brew install git-flow-avh
Examples
Example 1: Initializing Gitflow on a repository
git flow init asks which branch is production and which is the integration branch, and what prefixes to use for supporting branches. Passing -d accepts all the (sensible) defaults without prompting.
git flow init -d
Output:
Using default branch names.
Which branch should be used for bringing forth production releases?
- main
Branch name for production releases: main
Branch name for "next release" development: develop
How to name your supporting branch prefixes?
Feature branches? [feature/]
Release branches? [release/]
Hotfix branches? [hotfix/]
Support branches? [support/]
Version tag prefix? []
Switched to a new branch 'develop'
This created a second permanent branch, develop, alongside main, and switched your working tree onto it. From this point on, all feature work starts from develop, never from main.
Example 2: Starting and finishing a feature branch
git flow feature start login-page
# ... edit src/login.js and src/login.css ...
git add src/login.js src/login.css
git commit -m "feat: add login page markup and styles"
Output:
Switched to a new branch 'feature/login-page'
Summary of actions:
- A new branch 'feature/login-page' was created, based on 'develop'
- You are now on branch 'feature/login-page'
[feature/login-page 3a1c9de] feat: add login page markup and styles
2 files changed, 48 insertions(+)
create mode 100644 src/login.css
create mode 100644 src/login.js
Once the feature is complete, finishing it merges it back into develop and deletes the temporary branch:
git flow feature finish login-page
Output:
Switched to branch 'develop'
Merge made by the 'ort' strategy.
src/login.css | 30 ++++++++++++++++++++++++++++++
src/login.js | 18 ++++++++++++++++++
2 files changed, 48 insertions(+)
Deleted branch feature/login-page (was 3a1c9de).
Summary of actions:
- The feature branch 'feature/login-page' was merged into 'develop'
- Feature branch 'feature/login-page' has been locally deleted
- You are now on branch 'develop'
If you don’t have the extension installed, the plain Git version of that exact same feature branch looks like this:
git switch develop
git switch -c feature/login-page
# ... edit files, then ...
git add src/login.js src/login.css
git commit -m "feat: add login page markup and styles"
git switch develop
git merge --no-ff feature/login-page -m "Merge branch 'feature/login-page' into develop"
git branch -d feature/login-page
Output: the same merge commit on develop, created explicitly by --no-ff, and the feature branch removed. If you left off --no-ff here and develop hadn’t moved since branching, Git would fast-forward instead of merging, and the graph would no longer show that this work was ever a separate feature.
Example 3: Cutting a release, then shipping a hotfix
When develop has accumulated enough finished features, start a release branch to stabilize it:
git flow release start 1.2.0
# bump version number, update changelog
git add package.json CHANGELOG.md
git commit -m "chore: bump version to 1.2.0"
git flow release finish 1.2.0
Output:
Switched to a new branch 'release/1.2.0'
[release/1.2.0 9f4e2ab] chore: bump version to 1.2.0
2 files changed, 6 insertions(+), 1 deletion(-)
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.2.0 (was 9f4e2ab).
Summary of actions:
- Release branch 'release/1.2.0' has been merged into 'main'
- The release was tagged '1.2.0'
- Release branch has been back-merged into 'develop'
- Release branch 'release/1.2.0' has been locally deleted
- You are now on branch 'develop'
A week later, a rounding bug is found in production. Since main already reflects 1.2.0, branch a hotfix straight off it:
git flow hotfix start 1.2.1
git add src/payment.js
git commit -m "fix: correct rounding error in checkout total"
git flow hotfix finish 1.2.1
Output: mirrors the release finish above — merged and tagged on main as 1.2.1, back-merged into develop (and into any in-progress release branch), and the hotfix branch deleted. That back-merge into develop is what stops the same rounding bug from reappearing in 1.3.0.
How it works step by step
When git flow feature finish login-page (or its plain-Git equivalent) runs, Git performs, in order:
- Checkout
develop: the working tree and index are updated to matchdevelop‘s tip commit;HEADis repointed to thedevelopref. - Compute the merge: Git finds the common ancestor of
developandfeature/login-page, then computes the combined diff of both branches’ changes since that ancestor and applies it to the index and working tree. - Create the merge commit: because
--no-ffwas requested, Git writes a new commit object whose tree is the merged snapshot and whose parent list is[develop-tip, feature-tip]— two parents, unlike a normal commit’s one. - Move the
developpointer: thedevelopref is updated to point at this new merge commit. - Delete the feature branch: only the pointer is removed — the commits themselves stay in the object database (reachable through the merge commit’s second parent) until garbage collected.
A release or hotfix finish repeats steps 1–3 twice (once against main, once against develop) and adds a git tag on the commit that landed on main.
Common Mistakes
Merging a feature straight into main:
git switch main
git merge feature/login-page
git push origin main
This runs without error, but it breaks the model: main should only ever receive merges from release or hotfix branches. Untagged, unreleased work now sits on the branch you promised always reflects production. The fix is to always merge features into develop and let a release branch carry them to main.
Forgetting --no-ff:
git switch develop
git merge feature/login-page
If develop hasn’t advanced since the branch point, Git fast-forwards instead of creating a merge commit — the commits land fine, but the graph no longer shows that they were ever an isolated feature, making git log --graph far less useful for auditing what shipped in each release. Always pass --no-ff explicitly when merging manually (the git flow extension already does this for you).
Leaving conflict markers in a commit: a rushed merge resolution can accidentally commit the raw markers instead of the resolved code:
<<<<<<< HEAD
const MAX_RETRIES = 3;
=======
const MAX_RETRIES = 5;
>>>>>>> feature/login-page
If this ends up in a commit, the file is now syntactically broken. Before committing a merge, always search the diff for <<<<<<< and run your build/tests, not just git status.
Letting feature branches live too long: a feature/* branch that sits open for weeks while develop keeps moving accumulates drift, and the eventual merge turns into a painful conflict-resolution session. Merge develop back into the feature branch periodically (or rebase it, since a feature branch is still private) to keep the eventual finish small.
Best Practices
- Always use
--no-fffor feature, release, and hotfix merges so the commit graph preserves branch structure. - Keep feature branches short-lived; merge or rebase in
develop‘s latest changes regularly rather than letting them diverge for weeks. - Tag every commit that lands on
mainwith the version it represents (git tag -a 1.2.0 -m "Release 1.2.0"), and push tags withgit push --tags. - Always back-merge release and hotfix branches into
develop— a fix that only lands onmainreappears in the next release. - Use Conventional Commits (
feat:,fix:,chore:) so release notes and changelogs can be generated from history. - Never rebase
developormain— they’re shared, long-lived branches; rebasing rewrites history everyone else already has. - Reserve Gitflow for projects with discrete, versioned releases (libraries, installed software, mobile apps). For a continuously deployed web service, a lighter model like GitHub Flow (short-lived branches merged straight into
mainvia pull request) usually causes less overhead.
Practice Exercises
- Initialize Gitflow in a fresh repository (
git flow init -dor the manualdevelopbranch equivalent), start a feature branch calledfeature/user-avatar, commit one change, and finish it. Confirm withgit log --graph --oneline --allthat a merge commit with two parents was created ondevelop. - Simulate a release: from
develop, startrelease/2.0.0, commit a version bump, finish the release, and verify withgit tagthat2.0.0now exists and thatmainanddevelopboth contain the version-bump commit. - Simulate a production emergency: from
main, starthotfix/2.0.1, commit a one-line fix, finish it, and verify the fix is present on all three ofmain,develop, and the tag2.0.1— without ever having mergeddevelopinto the hotfix branch.
Summary
- Gitflow uses two permanent branches,
main(production, tagged) anddevelop(integration), plus temporaryfeature/*,release/*, andhotfix/*branches. - Feature branches come from and return to
developonly; release and hotfix branches are the only paths ontomain, and both merge back intodeveloptoo. --no-ffmerges create an explicit two-parent commit so the branch structure stays visible in the history graph, instead of being erased by a fast-forward.- The
git-flowextension automates this exact sequence of plain Git commands (switch,merge --no-ff,tag,branch -d); it isn’t required to follow the model. - Gitflow suits versioned, scheduled-release software; continuously deployed services often do better with a simpler branch model.
