Git Workflows — Street-Level Ops¶
Real-world patterns and operational recipes for implementing and migrating between Git workflows in production.
Quick Diagnosis Commands¶
# What branches exist and when were they last committed to?
git for-each-ref --sort=-committerdate refs/heads/ \
--format='%(committerdate:short) %(refname:short) %(subject)'
# How old is this feature branch? (days since it diverged from main)
git log main..feature/x --oneline | wc -l
# Is main protected? (GitHub CLI)
gh api repos/{owner}/{repo}/branches/main/protection 2>/dev/null \
&& echo "Protected" || echo "NOT protected"
# What merge strategy does this repo use? (check for squash vs merge commits)
git log --oneline --merges -10 main
# Find branches that have been merged and can be cleaned up
git branch --merged main | grep -v '^\* \|main\|develop\|release'
Branch Protection Setup¶
Branch protection is the enforcement mechanism for any workflow. Without it, your workflow is a suggestion, not a rule.
GitHub Branch Protection (via CLI)¶
# Require PR reviews, CI status checks, and linear history
gh api repos/{owner}/{repo}/branches/main/protection \
--method PUT \
--field required_status_checks='{"strict":true,"contexts":["ci/tests","ci/lint"]}' \
--field enforce_admins=true \
--field required_pull_request_reviews='{"required_approving_review_count":1}' \
--field restrictions=null
# Require signed commits (high-security environments)
gh api repos/{owner}/{repo}/branches/main/protection/required_signatures \
--method POST
Protection Rules by Workflow¶
| Workflow | Required on main |
Additional |
|---|---|---|
| Trunk-Based | CI status checks, no force push | Allow direct push (no PR required) OR require PR with auto-merge |
| GitHub Flow | PR required, CI checks, 1+ review | Block direct push, require up-to-date branches |
| GitFlow | PR required, CI checks, 1+ review | Same rules on develop; release/* branches: CI only |
| GitLab Flow | PR required, CI checks | Same rules on staging, production branches |
| Release Branching | CI checks, signed tags | release/* branches: restrict push to release managers |
Street rule: If
mainis not protected, your workflow does not exist. It is just a convention that the first tired engineer at 2 AM will violate.
Hotfix Patterns by Workflow¶
Every workflow needs an answer to: "Production is on fire. How do I ship a fix in 30 minutes?"
Trunk-Based Hotfix¶
# 1. Fix directly on main (or a tiny branch)
git checkout main && git pull
git checkout -b hotfix/payment-crash
# ... make the fix, run tests ...
git push -u origin hotfix/payment-crash
# 2. Open PR, get emergency review (or auto-merge if you have that configured)
gh pr create --title "fix: payment crash on null amount" --body "HOTFIX"
# 3. Merge and deploy (automated pipeline picks it up)
gh pr merge --squash
# Time to production: 10-30 minutes
Key insight: In trunk-based, a hotfix is just a normal commit. There is no ceremony.
GitHub Flow Hotfix¶
# Same as trunk-based — it is just a PR to main
git checkout -b hotfix/payment-crash main
# ... fix ...
git push -u origin hotfix/payment-crash
gh pr create --title "fix: payment crash" --label "hotfix"
# Get expedited review, merge, deploy
Key insight: Identical to a regular change. Add a "hotfix" label for visibility.
GitFlow Hotfix¶
# 1. Branch from main (production), NOT develop
git checkout -b hotfix/1.2.1 main
# 2. Fix and bump version
# ... make the fix ...
echo "1.2.1" > VERSION
git add -A && git commit -m "fix: payment crash on null amount"
# 3. Merge to BOTH main AND develop
git checkout main && git merge --no-ff hotfix/1.2.1
git tag -a v1.2.1 -m "Hotfix: payment crash"
git checkout develop && git merge --no-ff hotfix/1.2.1
# 4. Delete hotfix branch
git branch -d hotfix/1.2.1
# 5. Push everything
git push origin main develop --tags
Key insight: GitFlow hotfixes must land on both main and develop. Forgetting the develop merge is the most common GitFlow mistake.
Mnemonic: "Hotfix hits home AND dev." H-H-A-D. Both branches must get the fix.
GitLab Flow Hotfix¶
# 1. Fix on main first (following the "upstream first" principle)
git checkout -b hotfix/payment-crash main
# ... fix ...
git push -u origin hotfix/payment-crash
# Merge PR to main
# 2. Cherry-pick to production (or merge main -> staging -> production)
git checkout production
git cherry-pick <fix-commit-sha>
git push origin production
# Production deployment triggers automatically
Key insight: Fix upstream first, then promote downstream. Never fix directly on the production branch.
Release Branch Hotfix¶
# 1. Fix on main first
git checkout -b fix/payment-crash main
# ... fix and merge to main ...
# 2. Cherry-pick to each affected release branch
git checkout release/1.2
git cherry-pick <fix-sha>
git tag v1.2.5
git push origin release/1.2 --tags
git checkout release/1.1
git cherry-pick <fix-sha>
git tag v1.1.12
git push origin release/1.1 --tags
Key insight: Fix on main, then backport. Never fix on a release branch without also fixing main (or you create cherry-pick drift).
Mnemonic: "Fix forward, port backward." Always fix on the newest branch first.
Release Cutting Recipes¶
Tag-Based Release (TBD / GitHub Flow)¶
# Tag the current main
git tag -a v2.4.0 -m "Release 2.4.0: new billing engine"
git push origin v2.4.0
# CI pipeline detects the tag and builds release artifacts
# Changelog generated from commits since last tag:
git log v2.3.0..v2.4.0 --oneline --no-merges
Branch-Based Release (GitFlow)¶
# Cut release branch from develop
git checkout -b release/2.4 develop
# Stabilize: only bugfixes, version bumps, changelog updates
# ... qa testing, fix bugs ...
git commit -m "fix: correct tax calculation rounding"
git commit -m "chore: bump version to 2.4.0"
# Finalize: merge to main and tag
git checkout main
git merge --no-ff release/2.4
git tag -a v2.4.0 -m "Release 2.4.0"
# Back-merge to develop
git checkout develop
git merge --no-ff release/2.4
# Clean up
git branch -d release/2.4
git push origin main develop --tags
Changelog Generation¶
# Conventional commits make this automatable
# Using git-cliff, conventional-changelog, or similar:
git log v2.3.0..HEAD --pretty=format:"- %s (%h)" --no-merges
# Categorized (if using conventional commits):
git log v2.3.0..HEAD --pretty=format:"%s" --no-merges | \
grep "^feat:" | sed 's/^feat: //'
# Repeat for fix:, chore:, docs:, etc.
PR Review Patterns¶
Stacked PRs (for Large Features)¶
When a feature is too big for one PR, break it into a chain:
# Create the stack
git checkout -b feat/billing-model main
# ... data model changes ...
git push -u origin feat/billing-model
git checkout -b feat/billing-api feat/billing-model
# ... API changes ...
git push -u origin feat/billing-api
git checkout -b feat/billing-ui feat/billing-api
# ... UI changes ...
git push -u origin feat/billing-ui
# PR#1: feat/billing-model → main
# PR#2: feat/billing-api → feat/billing-model
# PR#3: feat/billing-ui → feat/billing-api
# Review and merge bottom-up
Tooling: gh-stack, git-branchless, Graphite, or Aviator handle stacked PRs natively.
Draft PRs for Early Feedback¶
gh pr create --draft --title "WIP: new billing engine" \
--body "Not ready for review. Looking for architecture feedback on the event model."
Mark ready when done: gh pr ready
Review Trains (High-Throughput Teams)¶
For teams merging 20+ PRs/day, assign a review train conductor who batches and orders PR merges to minimize CI queue time. GitHub's merge queue feature automates this:
# Enable merge queue on the repo
gh api repos/{owner}/{repo}/branches/main/protection \
--method PUT \
--field required_status_checks='{"strict":true,"contexts":["ci"]}' \
--field "merge_queue_enabled=true"
Monorepo Branching Considerations¶
Monorepos add complexity to any workflow because one branch contains multiple services.
Path-based CI triggers:
# GitHub Actions: only run service-a tests when service-a changes
on:
push:
paths:
- 'services/service-a/**'
- 'libs/shared/**'
CODEOWNERS for review routing:
# .github/CODEOWNERS
/services/billing/ @billing-team
/services/auth/ @auth-team
/libs/shared/ @platform-team
Release strategy: Tag per service (billing-v2.3.0, auth-v1.1.0) or use a unified version.
Street rule: In a monorepo, trunk-based development with feature flags is almost always the right choice. GitFlow in a monorepo is a recipe for merge nightmares.
Migration Playbook: GitFlow to Trunk-Based¶
This is the most common migration. It typically takes 2-8 weeks for a team of 5-15 developers.
Phase 1: Assessment (Week 1)¶
# How many long-lived branches exist?
git branch -r | wc -l
# How old is the oldest active branch?
git for-each-ref --sort=committerdate refs/remotes/ \
--format='%(committerdate:short) %(refname:short)' | head -10
# How long do feature branches typically live?
# (Look at recently merged PRs)
gh pr list --state merged --limit 20 --json mergedAt,createdAt \
--jq '.[] | "\(.createdAt) → \(.mergedAt)"'
Checklist: - [ ] CI pipeline runs in <15 minutes - [ ] Test suite has >80% coverage on critical paths - [ ] Team understands feature flags (or is willing to learn) - [ ] No hard dependency on release branches for current products
Phase 2: Preparation (Week 2-3)¶
- Set up feature flags — LaunchDarkly, Unleash, Flipt, or even environment variables
- Speed up CI — parallelize tests, cache dependencies, use faster runners
- Tighten branch protection — require CI and reviews on main
- Kill the
developbranch — merge develop into main, make main the integration target
# Merge develop into main (the big moment)
git checkout main
git merge develop
git push origin main
# Update default branch in GitHub/GitLab settings
gh api repos/{owner}/{repo} --method PATCH --field default_branch=main
# Delete develop
git push origin --delete develop
Phase 3: Transition (Week 3-5)¶
- New rule: Feature branches must live <3 days (enforce via bot or PR age alerts)
- New rule: All PRs target
maindirectly (no more develop) - Teach the team: Feature flags replace long-lived branches for incomplete work
- Monitor: Track branch lifetime and merge frequency
Phase 4: Optimization (Week 5-8)¶
- Reduce branch lifetime target from 3 days to 1 day
- Consider enabling auto-merge for PRs that pass all checks
- Set up continuous deployment from main
- Retrospect: what is still causing long-lived branches? Fix the root cause.
War story: A 30-person team migrated from GitFlow to trunk-based over 6 weeks. The hardest part was not the technical migration — it was convincing senior developers that "commit to main" was not reckless. The key argument: "Your tests are the safety net, not your branch." Merge conflicts dropped 90% in the first month.
Common Migration Mistakes¶
- Killing develop before CI is ready — if your tests are flaky, trunk-based will break main daily
- Not training on feature flags — developers will create long branches "just this once"
- Going cold turkey — gradual reduction in branch lifetime works better than a hard cutover
- Forgetting to update CI pipelines — GitFlow CI often has branch-specific rules that need cleanup
Workflow Selection Decision Shortcuts¶
When advising a team, use these quick heuristics:
| Signal | Workflow |
|---|---|
| "We deploy on Fridays" | GitHub Flow (weekly cadence, not daily) |
| "We ship v3.2.1 to customers" | GitFlow or Release Branching |
| "We deploy 10 times a day" | Trunk-Based Development |
| "We have staging and prod" | GitLab Flow |
| "We support v1.x and v2.x" | Release Branching |
| "We have 3 developers" | GitHub Flow (simplest that works) |
| "We have 200 developers in a monorepo" | Trunk-Based Development |
| "Our QA team needs a freeze period" | GitFlow (release branches = freeze) |
Mnemonic: "Match the workflow to the cadence, not the team's ambition." Teams that deploy weekly should not adopt trunk-based because they want to deploy daily — adopt it when you actually deploy daily.