Git and GitFlow branching workflow for teams
Route yourself to the right branching workflow from your context, then standardize it with a branch-naming regex, a Conventional Commits table, and the branch-protection, CODEOWNERS, and commitlint config that make the standard survive a real team.
Which branching workflow actually fits your team?#
Most guides teach GitFlow's mechanics as if the mechanics were the question. They are not. The question is which model fits how your team actually ships, and that is decided by five inputs: your release model, your versioning scheme, your compliance and audit needs, your team size, and your CI/CD maturity. Find your row, read across, and pick the column that matches most of your answers.
| Your context | GitFlow | GitHub Flow | GitLab Flow | Trunk-based |
|---|---|---|---|---|
| Release model | GitFlowScheduled, versioned releases you support in parallel | GitHub FlowContinuous deploy of one production line | GitLab FlowContinuous, plus environment or release branches | Trunk-basedContinuous deploy, many small merges a day |
| Versioning | GitFlowSemVer with several live versions | GitHub FlowRolling, latest only | GitLab FlowSemVer or rolling, release branches optional | Trunk-basedRolling, latest only |
| Compliance / audit | GitFlowStrong: explicit release and hotfix branches | GitHub FlowLight: pull-request history only | GitLab FlowMedium: environment branches map to stages | Trunk-basedNeeds feature flags plus strong CI to stay auditable |
| Team size | GitFlowLarger teams shipping desktop, mobile, or on-prem builds | GitHub FlowSmall to mid-size web teams | GitLab FlowMid-size teams with staged environments | Trunk-basedAny size, given mature CI |
| CI/CD maturity | GitFlowWorks without heavy automation | GitHub FlowNeeds solid pull-request CI | GitLab FlowNeeds environment pipelines | Trunk-basedNeeds fast, trusted CI and feature flags |
If most of your answers sit in the last two columns, standardize a simpler model and skip to the migration section at the end. If they sit in the GitFlow column, versioned software, parallel supported releases, audit pressure, then the rest of this page is your convention pack.
GitFlow in one diagram: the five branch types and how they merge#
GitFlow has two long-lived branches and three short-lived ones. main holds released, tagged code. develop is the integration branch. feature/* branches off develop and merges back. release/* stabilizes a version, then merges to both main and develop. hotfix/* branches off main to patch production, then merges back to both. The trap is the two long-lived branches and the back-merges, which is exactly the overhead the simpler models remove.
When is GitFlow the wrong default?#
The honest answer comes from the person who designed it. Vincent Driessen published GitFlow in 2010 for software with explicit, versioned releases. Ten years later he added a note to the top of that same post.
So GitFlow is not wrong, it is specific. It earns its overhead when you support several versions in parallel and cut deliberate releases. When you deploy the latest commit to one production line all day, the two long-lived branches and the back-merges are cost with no return.
The evidence: long-lived branches versus trunk-based delivery#
This is not just one person's opinion. DORA's State of DevOps research, the largest running study of software delivery, measures how teams ship and correlates it with performance. The pattern it keeps finding is that short-lived branches integrated often beat long-lived branches integrated late. The gap in change lead time between the top and bottom groups is a magnitude you should feel, not look up.
Less than one day
Elite performers
One to six months
Low performers
DORA keeps finding the same shape: teams that merge small changes to trunk frequently and keep branch lifetimes short cluster at the top. Long-lived branches, the thing GitFlow encourages, correlate with the slow end. These are DORA's figures across the industry, not Atyantik client data.
| Option | time from code committed to running in production |
|---|---|
| Elite performers | Less than one day |
| Low performers | One to six months |
Branch naming your CI can actually enforce#
A naming standard is only real if a machine checks it. Below is the spec: each branch type, its prefix pattern, a concrete example, and the regex that accepts it. The last row is the single combined pattern you drop into a CI job so an off-standard branch fails before it wastes a review.
| Branch type | Prefix pattern | Example | Enforcing regex |
|---|---|---|---|
| Feature | Prefix patternfeature/<description> | Examplefeature/login-rate-limit | Enforcing regex^feature/[a-z0-9._-]+$ |
| Bug fix | Prefix patternbugfix/<description> | Examplebugfix/date-parse-offset | Enforcing regex^bugfix/[a-z0-9._-]+$ |
| Release | Prefix patternrelease/<version> | Examplerelease/2.4.0 | Enforcing regex^release/[0-9]+\.[0-9]+\.[0-9]+$ |
| Hotfix | Prefix patternhotfix/<version>-<description> | Examplehotfix/2.4.1-null-session | Enforcing regex^hotfix/[a-z0-9._-]+$ |
| Long-lived | Prefix patternliteral names | Examplemain, develop | Enforcing regex^(main|develop)$ |
| Combined (for CI) | Prefix patternone pattern for all | Exampleany of the above | Enforcing regex^((main|develop)|(feature|bugfix|hotfix|release|chore)/[a-z0-9._-]+)$ |
Commit messages: the Conventional Commits contract#
Standardizing branch names solves half the problem. The other half is commit messages, and Conventional Commits is the spec worth adopting because it is machine-readable. Each message starts with a type, which maps to a Semantic Versioning bump, so your release tooling can compute the next version and changelog from history alone. The format is type(scope): description, with a ! or a BREAKING CHANGE: footer for anything that breaks the public contract.
| Type | Use it for | SemVer bump |
|---|---|---|
| feat | Use it forA new feature | SemVer bumpMINOR |
| fix | Use it forA bug fix | SemVer bumpPATCH |
| perf | Use it forA performance improvement | SemVer bumpPATCH |
| docs | Use it forDocumentation only | SemVer bumpnone |
| style | Use it forFormatting, no code behavior change | SemVer bumpnone |
| refactor | Use it forCode change that is neither a feature nor a fix | SemVer bumpnone |
| test | Use it forAdding or correcting tests | SemVer bumpnone |
| build | Use it forBuild system or dependency changes | SemVer bumpnone |
| ci | Use it forCI configuration and scripts | SemVer bumpnone |
| chore | Use it forTooling and housekeeping, no source change | SemVer bumpnone |
| feat! or BREAKING CHANGE: | Use it forA breaking change to the public contract | SemVer bumpMAJOR |
Try it: does your branch name pass the standard?#
The pattern above is testable, so test it. Type a branch name or pick an example. The result flips between pass and fail live, and on a pass it highlights the two groups the regex captured, the type and the description, so you can see exactly what CI accepts.
feature / login-rate-limit
Type feature, then a slash, then a lowercase, dash-separated description.
Pattern: ^(feature|bugfix|hotfix|release|chore)\/([a-z0-9]+(?:[-_.][a-z0-9]+)*)$
Worked example: shipping a feature and a hotfix, command by command#
Conventions are easier to keep once you have run the loop once. Here is the full trace for the two flows that define GitFlow: a normal feature, and a production hotfix that has to reach both branches.
A feature. Branch off develop, commit with a Conventional Commit, and open a pull request back into develop. Nothing touches main yet.
# Start from an up-to-date develop
git checkout develop
git pull origin develop
# Branch with the naming standard
git checkout -b feature/login-rate-limit
# Do the work, then commit with a Conventional Commit
git add src/auth/rate-limit.ts
git commit -m "feat(auth): limit login attempts to 5 per minute"
# Push and open a pull request into develop
git push -u origin feature/login-rate-limit
gh pr create --base develop --fill A release. When the scope for a version is frozen, cut a release branch off develop. Only stabilizing commits land on it. Merging it to main and tagging is what actually ships the version, and the back-merge into develop keeps any last fixes.
# Cut a release branch off develop once the scope is frozen
git checkout develop && git pull
git checkout -b release/2.4.0
# Only stabilizing commits land here: version bump, changelog, last fixes
git commit -am "chore(release): 2.4.0"
# Merge to main, tag the release, then back-merge into develop
git checkout main && git merge --no-ff release/2.4.0
git tag -a v2.4.0 -m "Release 2.4.0"
git checkout develop && git merge --no-ff release/2.4.0
git push origin main develop --tags A hotfix. This is the flow people get wrong. Production is broken, so you branch off main, not develop, because develop may hold unreleased work you cannot ship. After the fix, you merge to main and tag the patch, then back-merge into develop so the next release does not silently reintroduce the bug.
# Production is broken. Branch straight off main, never off develop
git checkout main && git pull
git checkout -b hotfix/2.4.1-null-session
git commit -am "fix(auth): guard against a null session on token refresh"
# Merge to main, tag the patch, then back-merge so develop keeps the fix
git checkout main && git merge --no-ff hotfix/2.4.1-null-session
git tag -a v2.4.1 -m "Hotfix 2.4.1"
git checkout develop && git merge --no-ff hotfix/2.4.1-null-session
git push origin main develop --tags Make the standard stick: branch protection, CODEOWNERS, commitlint#
A convention that lives in a wiki page is a suggestion. A convention wired into the repository is a standard. Three layers turn the spec above into something a team cannot drift from: protect the branch, route the review, and lint the commit.
Protect the branch. Require a passing CI run and at least one approving review before anything merges to main. This is where you wire in the branch-name regex and the commit lint as required checks.
# Require a review and green checks before anything merges to main
gh api --method PUT repos/:owner/:repo/branches/main/protection \
--input - <<'JSON'
{
"required_pull_request_reviews": { "required_approving_review_count": 1 },
"required_status_checks": { "strict": true, "contexts": ["ci/test", "ci/lint"] },
"enforce_admins": true,
"restrictions": null
}
JSON Route the review. A CODEOWNERS file sends each pull request to the people who own the touched paths, so security changes reach the security group and infrastructure changes reach the DevOps group without anyone remembering to add them.
# .github/CODEOWNERS: route each review to the people who own the path
* @acme/platform
/src/auth/ @acme/security
/infra/ @acme/devops
*.sql @acme/data Lint the commit. commitlint checks every message against the Conventional Commits contract and rejects the ones that break it. Wired to a Git hook, a bad message never reaches the remote.
// commitlint.config.js: reject commits that break the contract
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore',
]],
'subject-case': [2, 'never', ['upper-case', 'pascal-case']],
},
}; #!/usr/bin/env sh
# .husky/commit-msg: run commitlint on every commit message
npx --no-install commitlint --edit "$1" With those three in place, the branch-naming regex, the commit contract, and the review routing all enforce themselves. New team members learn the standard by bouncing off it once, not by reading a document.
A branching standard only holds if the work entering and leaving a branch is disciplined too. For what has to pass before a merge is allowed, read how to allocate testing by risk rather than spreading it evenly across every branch. For how work gets decided and routed before anyone cuts a branch at all, running a bug triage as a room rather than a queue covers the process this convention pack assumes is already in place.
When to migrate off GitFlow, and how to do it safely#
Return to the decision table. If you deploy continuously to one production line, GitFlow's two long-lived branches and back-merges are pure overhead, and the DORA evidence says the long-lived branches actively slow you down. The honest move is to migrate, and there are two lighter targets.
- GitHub Flow keeps one long-lived branch, main, plus short-lived branches that merge back through a reviewed pull request. It fits small to mid-size web teams deploying the latest commit.
- Trunk-based development goes further: everyone integrates to trunk at least daily, branches live hours not weeks, and unfinished work hides behind feature flags instead of long-lived branches. It needs fast, trusted CI and a feature-flag habit, and it is what the elite performers in the DORA data tend to run.
Migrate in low-risk steps rather than in one cut. Shorten branch lifetimes first, so branches merge in days not weeks. Add feature flags so half-finished work can sit on trunk safely. Move release cutting to a tag on main instead of a release branch. Then retire develop once nothing depends on it. Each step is reversible, and none of them requires a flag day.
The reverse is also true, and worth saying plainly: do not adopt trunk-based development if you ship versioned software your customers install and you support several versions at once. That is precisely the case GitFlow was built for, and forcing trunk-based development onto it trades a fit for a fashion.