Choosing the right Git workflow is one of the highest-leverage decisions a team makes. The wrong choice creates merge hell, broken deploys, and slow release cycles. This guide covers the four most common strategies so you can pick — and stick with — the right one.
Quick reference
| Workflow | Best for | Release cadence | Complexity |
|---|---|---|---|
| GitFlow | Versioned software, mobile apps | Scheduled (weekly/monthly) | High |
| GitHub Flow | Web apps, SaaS | Continuous (multiple/day) | Low |
| GitLab Flow | Apps with environments | Continuous + env gates | Medium |
| Trunk-Based Dev | High-velocity teams | Continuous | Low–Medium |
| Forking | Open source | Variable | Medium |
GitFlow
Introduced by Vincent Driessen in 2010, GitFlow uses two long-lived branches (main and develop) plus three types of short-lived branches.
Branch types
| Branch | Created from | Merges into | Purpose |
|---|---|---|---|
main |
— | — | Production-ready code only |
develop |
main |
— | Integration branch |
feature/* |
develop |
develop |
New features |
release/* |
develop |
main + develop |
Release prep (bug fixes, version bump) |
hotfix/* |
main |
main + develop |
Emergency production fixes |
Setup with git-flow CLI
# Install
brew install git-flow-avh # macOS
apt install git-flow # Ubuntu
# Initialise (accept defaults)
git flow init
# Feature
git flow feature start my-feature
git flow feature finish my-feature # merges to develop, deletes branch
# Release
git flow release start 1.2.0
# bump version, update CHANGELOG
git flow release finish 1.2.0 # merges to main + develop, tags main
# Hotfix
git flow hotfix start fix-crash
git flow hotfix finish fix-crash # merges to main + develop
Manual GitFlow (no CLI)
# Feature
git checkout develop
git checkout -b feature/search-bar
# ... work ...
git checkout develop
git merge --no-ff feature/search-bar
git branch -d feature/search-bar
# Release
git checkout -b release/1.2.0 develop
# bump version in package.json, etc.
git checkout main
git merge --no-ff release/1.2.0
git tag -a v1.2.0 -m "Release 1.2.0"
git checkout develop
git merge --no-ff release/1.2.0
git branch -d release/1.2.0
# Hotfix
git checkout -b hotfix/fix-crash main
# ... fix ...
git checkout main
git merge --no-ff hotfix/fix-crash
git tag -a v1.2.1 -m "Hotfix 1.2.1"
git checkout develop
git merge --no-ff hotfix/fix-crash
git branch -d hotfix/fix-crash
When to use GitFlow
- Desktop apps, mobile apps, or libraries with versioned releases
- Teams that ship on a scheduled cadence (weekly, biweekly, monthly)
- Projects that must maintain multiple versions simultaneously (v1.x, v2.x)
When NOT to use GitFlow
- Web apps that deploy multiple times per day — the ceremony slows you down
- Small teams (< 5 people) — the overhead exceeds the benefit
- Microservices — each service has its own repo and deploy cycle
GitHub Flow
A simpler model: one long-lived branch (main), always deployable, and short-lived feature branches.
The six steps
1. Create a branch from main
2. Add commits
3. Open a Pull Request
4. Discuss and review
5. Merge (after CI passes + approval)
6. Deploy
# 1. Create branch
git checkout main
git pull
git checkout -b feature/user-avatar
# 2. Work and commit
git add .
git commit -m "feat: add user avatar upload"
git push -u origin feature/user-avatar
# 3. Open PR (via GitHub UI or CLI)
gh pr create --title "Add user avatar upload" --body "..."
# 4. After approval and CI pass, merge via UI or:
gh pr merge --squash --delete-branch
# 5. main is now updated — deploy runs automatically via CI/CD
Rules for GitHub Flow to work
mainmust always be deployable (enforce this with branch protection rules)- Feature branches are short-lived (< 2 days ideally)
- CI must pass before merge
- Review is required (at least 1 approval)
GitHub branch protection setup
Settings → Branches → Add rule → Branch name: main
☑ Require a pull request before merging
☑ Require approvals: 1
☑ Require status checks to pass
☑ Require branches to be up to date
☑ Do not allow bypassing the above settings
When to use GitHub Flow
- Web apps and SaaS products that deploy continuously
- Small-to-medium teams (2–20 people)
- Projects where
mainis always production
GitLab Flow
A middle ground between GitFlow and GitHub Flow. Adds environment branches (staging, production) so that main isn't automatically deployed everywhere.
Branch hierarchy
feature/* → main → staging → production
↘ hotfix/* ↗
# Feature work (same as GitHub Flow)
git checkout -b feature/dark-mode
# ... commits ...
git push origin feature/dark-mode
# open MR into main
# Promote to staging
git checkout staging
git merge main
git push origin staging
# CI deploys to staging environment
# Promote to production
git checkout production
git merge staging
git push origin production
# CI deploys to production
Environment branches
| Branch | Environment | Who merges |
|---|---|---|
main |
Development / CI | Devs (via MR) |
staging |
Staging | Team lead / auto after tests |
production |
Production | Release manager |
Release tags instead of branches
For versioned software, GitLab Flow uses tags on main rather than release branches:
git tag -a v2.3.0 -m "Release 2.3.0"
git push origin v2.3.0
# CI triggers production deploy on tag push
When to use GitLab Flow
- Apps with distinct staging/production environments
- Regulated industries where production deploys need a gate
- Teams that want GitHub Flow simplicity but more deployment control
Trunk-Based Development
Everyone commits directly to main (the "trunk") — or uses very short-lived branches (< 1 day). No long-running feature branches.
Core practices
# Short-lived branch (< 1 day)
git checkout -b feat-login-button
# ... small, focused commit ...
git push origin feat-login-button
# PR reviewed and merged same day
# Or direct commit to trunk (small teams, senior devs)
git checkout main
git pull
# ... small change ...
git commit -m "fix: correct button label"
git push
Feature flags — the key enabler
Trunk-based development relies on feature flags to ship incomplete features safely:
// config/features.ts
export const FEATURES = {
NEW_CHECKOUT: process.env.NEXT_PUBLIC_FF_NEW_CHECKOUT === 'true',
DARK_MODE: process.env.NEXT_PUBLIC_FF_DARK_MODE === 'true',
} as const;
// components/Checkout.tsx
import { FEATURES } from '@/config/features';
export function CheckoutButton() {
if (FEATURES.NEW_CHECKOUT) {
return <NewCheckoutFlow />;
}
return <LegacyCheckout />;
}
# .env.production — feature off
NEXT_PUBLIC_FF_NEW_CHECKOUT=false
# Enable for 10% of users (via LaunchDarkly, Unleash, etc.)
# or flip to true when ready
Branch-by-abstraction (for large refactors)
# Step 1: Create abstraction
class PaymentGateway:
def charge(self, amount: int, token: str) -> Receipt:
raise NotImplementedError
# Step 2: Old implementation behind abstraction
class StripeGateway(PaymentGateway):
def charge(self, amount, token):
return stripe.charge(amount, token)
# Step 3: New implementation (deploy but not activated)
class BraintreeGateway(PaymentGateway):
def charge(self, amount, token):
return braintree.sale(amount, token)
# Step 4: Switch via config, keep both, delete old once stable
gateway = BraintreeGateway() if FEATURES.NEW_PAYMENT else StripeGateway()
When to use trunk-based development
- High-performing teams with strong CI/CD and test coverage (> 70%)
- Continuous deployment with multiple deploys per day
- Teams comfortable with feature flags
- Microservices where each service deploys independently
Trunk-based CI requirements
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test -- --coverage
- run: npm run build
CI must be fast (< 10 minutes) for trunk-based to work. Otherwise developers stop waiting and start bypassing.
Forking workflow (open source)
Used in open source where contributors don't have write access to the main repo.
# 1. Fork on GitHub (creates your copy)
# 2. Clone your fork
git clone https://github.com/YOUR_USERNAME/project.git
cd project
# 3. Add upstream remote
git remote add upstream https://github.com/ORIGINAL_OWNER/project.git
# 4. Keep fork in sync
git fetch upstream
git checkout main
git merge upstream/main
# 5. Create feature branch on your fork
git checkout -b fix-typo-in-readme
# 6. Push to your fork
git push origin fix-typo-in-readme
# 7. Open PR from your fork to upstream main (via GitHub UI)
Sync fork before starting work
# Always sync before starting a new branch
git fetch upstream
git rebase upstream/main
git push origin main --force-with-lease # only if you own main on fork
Commit message conventions
Consistent commit messages are important in all workflows. Conventional Commits is the most widely adopted standard:
<type>[optional scope]: <description>
[optional body]
[optional footer]
Types
| Type | When to use |
|---|---|
feat |
New feature |
fix |
Bug fix |
refactor |
Code change that is neither fix nor feature |
docs |
Documentation only |
test |
Adding or updating tests |
chore |
Build process, dependency updates |
perf |
Performance improvement |
ci |
CI/CD configuration |
style |
Formatting, missing semicolons (no logic change) |
revert |
Reverts a previous commit |
Examples
feat(auth): add OAuth2 login with GitHub
fix(cart): prevent duplicate items on rapid click
refactor: extract payment service from order controller
docs: add API authentication examples to README
test(user): add edge cases for email validation
chore: upgrade TypeScript to 5.4
perf(images): lazy load below-fold product images
ci: add Playwright E2E tests to PR checks
Enforce with commitlint
npm install -D @commitlint/cli @commitlint/config-conventional husky
# commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
# .husky/commit-msg
npx --no -- commitlint --edit "$1"
Merge strategies
Merge commit (--no-ff)
git merge --no-ff feature/search
Preserves branch history. Every merge is visible in git log --graph. GitFlow uses this exclusively.
Squash merge
git merge --squash feature/search
git commit -m "feat: add search functionality"
Collapses all commits from the feature branch into one. Keeps main history linear and clean. Good for GitHub Flow.
Rebase and merge
git rebase main feature/search
git checkout main
git merge --ff-only feature/search
Replays feature commits on top of main. Creates the cleanest linear history but rewrites SHAs — never rebase shared branches.
Which to choose
| Strategy | History | Use when |
|---|---|---|
| Merge commit | Non-linear (shows branches) | GitFlow, auditable history |
| Squash merge | Linear (one commit per feature) | GitHub Flow, clean PR history |
| Rebase merge | Linear (all commits preserved) | Teams that write clean commits |
Pull request best practices
PR size
Small PRs merge faster and get better reviews. Aim for < 400 lines changed.
# Check your PR size before pushing
git diff main...HEAD --stat
If your branch is > 400 lines:
- Split by layer (data/service/API separately)
- Use stacked PRs (PR2 targets PR1's branch, not main)
- Ship behind a feature flag
PR template
Create .github/pull_request_template.md:
## What
<!-- One sentence summary of the change -->
## Why
<!-- Context: link to issue, user story, or explain the problem -->
## How
<!-- Key technical decisions made -->
## Testing
- [ ] Unit tests added/updated
- [ ] Tested locally
- [ ] Edge cases considered
## Screenshots (if UI change)
Review checklist
| Check | What to look for |
|---|---|
| Correctness | Does it do what it claims? Are edge cases handled? |
| Tests | Are tests present? Do they cover the important paths? |
| Security | SQL injection, XSS, auth bypass, exposed secrets? |
| Performance | N+1 queries, missing indexes, large in-memory operations? |
| Naming | Are variables, functions, files named clearly? |
| Scope | Does the PR do more than one thing? Should it be split? |
Protecting main
Configure branch protection to enforce your workflow:
# Via GitHub CLI
gh api repos/{owner}/{repo}/branches/main/protection \
--method PUT \
--input - <<EOF
{
"required_status_checks": {
"strict": true,
"contexts": ["test", "build", "lint"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}
EOF
Useful branch naming conventions
# By type
feature/user-authentication
fix/login-redirect-loop
hotfix/payment-timeout
release/2.4.0
chore/upgrade-dependencies
docs/api-authentication
# By ticket number (ties to issue tracker)
feature/PROJ-123-user-authentication
fix/PROJ-456-login-redirect
Enforce naming with a branch name check in CI or a pre-push hook:
# .husky/pre-push
branch=$(git rev-parse --abbrev-ref HEAD)
pattern="^(main|develop|feature|fix|hotfix|release|chore|docs)/"
if [[ ! "$branch" =~ $pattern ]]; then
echo "Branch name '$branch' doesn't match pattern: $pattern"
exit 1
fi
Resolving merge conflicts
# 1. Fetch latest
git fetch origin
# 2. Merge or rebase
git rebase origin/main # or: git merge origin/main
# 3. Check conflicted files
git status
# Both modified: src/utils/auth.ts
# 4. Open and edit — conflict markers look like:
# <<<<<<< HEAD (your changes)
# function login() { ... }
# =======
# function login(redirect: string) { ... }
# >>>>>>> origin/main (their changes)
# 5. Resolve — keep what's correct, delete markers
# 6. Mark resolved
git add src/utils/auth.ts
git rebase --continue # or: git commit (if merge)
# 7. Verify
git log --oneline -5
git diff HEAD~1
Use a merge tool
# Configure VS Code as merge tool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
# Launch
git mergetool
Common workflow mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Long-lived feature branches | Massive merge conflicts | Keep branches < 2–3 days old |
Committing to main directly |
Bypasses review, can break CI | Enforce branch protection |
git push --force on shared branches |
Overwrites teammates' commits | Use --force-with-lease instead |
| Huge PRs (1000+ lines) | Impossible to review well | Split by concern; ship behind feature flags |
| No CI on PRs | Broken code reaches main | Require status checks before merge |
| Mixing refactor + feature in one PR | Hard to review, hard to revert | Separate PRs for separate concerns |
| Not deleting merged branches | Repo gets cluttered | Enable auto-delete in GitHub settings |
| Rebasing shared branches | Rewrites history, breaks teammates | Only rebase local/personal branches |
GitFlow vs GitHub Flow vs Trunk-Based
| Factor | GitFlow | GitHub Flow | Trunk-Based Dev |
|---|---|---|---|
| Long-lived branches | main + develop |
main only |
main only |
| Feature branches | Yes (merged to develop) | Yes (merged to main) | Yes, but < 1 day |
| Release branches | Yes | No | No (use tags) |
| Hotfix branches | Yes | Via feature branch | Via feature flag or fast PR |
| Deploy frequency | Scheduled | On merge to main | On every commit to main |
| Rollback strategy | Revert tag | Revert merge commit | Feature flag off |
| Required CI maturity | Low | Medium | High |
| Team size sweet spot | 5–50 | 2–20 | 5–200 |
| Cognitive overhead | High | Low | Low–Medium |
FAQ
Q: Should I use merge or rebase for feature branches?
Rebase keeps history linear and clean, making git log readable. Merge preserves the exact development history. Most teams use rebase for local cleanup (git rebase -i) and then either merge or squash when landing to main. Never rebase a branch that others have checked out.
Q: How long should a feature branch live?
Ideally 1–2 days, maximum 3–4 days. Branches older than a week accumulate drift from main and become expensive to merge. If a feature takes longer, break it into smaller deliverable chunks, or use feature flags to ship the incomplete work safely.
Q: When should I force-push?
Only to your own branches and only before a PR is opened (or after notifying your team). Use git push --force-with-lease instead of --force — it fails if someone else has pushed to the branch since your last fetch.
# Safe force-push
git push --force-with-lease origin feature/my-branch
Q: How do I undo a merge to main?
# Find the merge commit
git log --oneline --graph
# Revert it (creates a new commit that undoes the merge)
git revert -m 1 <merge-commit-hash>
git push
# If the branch wasn't deleted, you can re-open the PR later
# But first you need to revert the revert before re-merging
git revert <revert-commit-hash>
Q: What's the difference between GitFlow and GitHub Flow?
GitFlow maintains a develop branch as the integration point and uses release branches for QA/stabilisation. GitHub Flow treats main as always deployable and merges directly to it after code review. GitFlow suits teams with scheduled releases; GitHub Flow suits teams deploying continuously.
Q: How do I enforce a workflow on my team?
- Document it in
CONTRIBUTING.md - Enforce branch naming with a pre-push hook or CI check
- Require PR reviews and status checks via branch protection
- Use PR templates to standardise what's expected
- Automate the repetitive parts (auto-delete merged branches, auto-label PRs by branch prefix)