Skip to content

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 main is 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:

main ← PR#1 (data model) ← PR#2 (API endpoints) ← PR#3 (UI)
# 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)

  1. Set up feature flags — LaunchDarkly, Unleash, Flipt, or even environment variables
  2. Speed up CI — parallelize tests, cache dependencies, use faster runners
  3. Tighten branch protection — require CI and reviews on main
  4. Kill the develop branch — 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)

  1. New rule: Feature branches must live <3 days (enforce via bot or PR age alerts)
  2. New rule: All PRs target main directly (no more develop)
  3. Teach the team: Feature flags replace long-lived branches for incomplete work
  4. Monitor: Track branch lifetime and merge frequency

Phase 4: Optimization (Week 5-8)

  1. Reduce branch lifetime target from 3 days to 1 day
  2. Consider enabling auto-merge for PRs that pass all checks
  3. Set up continuous deployment from main
  4. 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

  1. Killing develop before CI is ready — if your tests are flaky, trunk-based will break main daily
  2. Not training on feature flags — developers will create long branches "just this once"
  3. Going cold turkey — gradual reduction in branch lifetime works better than a hard cutover
  4. 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.