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:

  1. Checkout develop: the working tree and index are updated to match develop‘s tip commit; HEAD is repointed to the develop ref.
  2. Compute the merge: Git finds the common ancestor of develop and feature/login-page, then computes the combined diff of both branches’ changes since that ancestor and applies it to the index and working tree.
  3. Create the merge commit: because --no-ff was 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.
  4. Move the develop pointer: the develop ref is updated to point at this new merge commit.
  5. 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-ff for 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 main with the version it represents (git tag -a 1.2.0 -m "Release 1.2.0"), and push tags with git push --tags.
  • Always back-merge release and hotfix branches into develop — a fix that only lands on main reappears in the next release.
  • Use Conventional Commits (feat:, fix:, chore:) so release notes and changelogs can be generated from history.
  • Never rebase develop or main — 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 main via pull request) usually causes less overhead.

Practice Exercises

  1. Initialize Gitflow in a fresh repository (git flow init -d or the manual develop branch equivalent), start a feature branch called feature/user-avatar, commit one change, and finish it. Confirm with git log --graph --oneline --all that a merge commit with two parents was created on develop.
  2. Simulate a release: from develop, start release/2.0.0, commit a version bump, finish the release, and verify with git tag that 2.0.0 now exists and that main and develop both contain the version-bump commit.
  3. Simulate a production emergency: from main, start hotfix/2.0.1, commit a one-line fix, finish it, and verify the fix is present on all three of main, develop, and the tag 2.0.1 — without ever having merged develop into the hotfix branch.

Summary

  • Gitflow uses two permanent branches, main (production, tagged) and develop (integration), plus temporary feature/*, release/*, and hotfix/* branches.
  • Feature branches come from and return to develop only; release and hotfix branches are the only paths onto main, and both merge back into develop too.
  • --no-ff merges 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-flow extension 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.