Mental Model
Git is a communication protocol as much as it is a version control system. Your branching strategy determines how fast your team ships, how easy incidents are to diagnose, and how often engineers block each other.
This playbook is for the real world: 10-to-50 engineer teams, multiple environments, CI/CD pipelines, and the occasional 3:00 AM production incident. Every pattern here has been tested in practice and has a concrete reason to exist.
The Branching Strategy Decision
The biggest architectural decision in a team's Git workflow is not which tool to use — it is how long feature branches live.
Trunk-Based Development (Recommended for Most Teams)
main ──────────●──────●──────●──────●──► (always deployable)
↑ ↑ ↑ ↑
feat-a feat-b fix-c feat-d
(1 day) (2 hrs) (1 hr) (3 days)
Rules:
- Feature branches live for 1 day maximum (ideally hours)
- If a feature takes longer, use feature flags to merge incomplete work safely
- Every merge to
maintriggers a deployment pipeline - Rollback = revert commit or flip the feature flag
Why trunk-based wins: Continuous integration is real integration. Long-lived branches are technical debt. A 1-day branch has minimal conflict risk. A 2-week branch is a merge disaster waiting to happen.
Git Flow (When You Have Fixed Release Cycles)
main ────────●───────────────────────────────●────►
↑ ↑
v1.0 v2.0
develop ──●──────●──────●──────●──────●──────►
↑ ↑ ↑ ↑ ↑
feat-a feat-b release fix hotfix
Use Git Flow when:
- You have scheduled release cycles (e.g., "ship every 2 weeks")
- Your deployment requires manual QA approval
- You manage multiple live versions simultaneously (mobile SDK, firmware, etc.)
Git Flow overhead is real: For SaaS web products with continuous delivery, Git Flow's branching overhead typically reduces deployment frequency from "multiple times daily" to "weekly." Only use it if that trade-off makes sense for your context.
Branch Protection Rules (Non-Negotiable)
Every production repository must have branch protection on main. Here is the GitHub branch protection configuration your team needs:
# Enforced via GitHub repository settings or Terraform
Branch Protection Rules for: main
├── Require pull request reviews before merging: YES
│ ├── Required approving reviews: 2 (1 for small teams)
│ ├── Dismiss stale reviews: YES
│ └── Require review from CODEOWNERS: YES
├── Require status checks to pass before merging: YES
│ ├── CI build must pass
│ ├── Test suite must pass
│ └── Lint + type check must pass
├── Require branches to be up to date: YES
├── Restrict who can push to matching branches: YES
│ └── Allow: Engineering managers only (for emergency)
└── Include administrators: YES (no exceptions)
The key insight: "Include administrators" is critical. If admins are exempt, your protection rules are theater — one stressed-out engineer with admin rights bypassing review at 2:00 AM is all it takes.
CODEOWNERS: Automatic Review Assignment
CODEOWNERS lives at .github/CODEOWNERS and automatically assigns reviewers based on which files were changed:
# .github/CODEOWNERS
# Global: all changes need at least one senior review
* @engineering/senior-engineers
# Payment system: always needs payment team review
/src/services/payment/ @engineering/payment-team
/src/api/checkout/ @engineering/payment-team
# Security-sensitive code: needs security team
/src/config/Security*.java @engineering/security-team
/src/auth/ @engineering/security-team
# Infrastructure: platform team only
/k8s/ @engineering/platform
/.github/workflows/ @engineering/platform
/terraform/ @engineering/platform
# Database migrations: DBA must review
/db/migrations/ @engineering/dba-team
# Docs: anyone can review
/docs/ @engineering/any
Why CODEOWNERS works: Engineers no longer have to figure out "who should review my PR?" The right reviewers are automatically assigned. Security changes get security reviews. Payment code changes get payment expert reviews. No exceptions, no guessing.
The Perfect Pull Request
A PR is a communication artifact as much as a code change. Here is the PR template that works for production engineering teams:
## What does this PR do?
<!-- One sentence. What is the actual change? -->
## Why?
<!-- What problem does this solve? Link to the ticket. -->
Closes: #TICKET-123
## How was this tested?
<!-- What did you run to verify this works? -->
- [ ] Unit tests added/updated
- [ ] Integration tests pass locally
- [ ] Tested in staging environment
- [ ] Manual test scenario: [describe what you clicked/called]
## Deployment notes
<!-- Does this require a feature flag? A config change? A migration? -->
- [ ] No deployment notes needed
- [ ] Requires feature flag: `feature.new_checkout_flow`
- [ ] Requires DB migration (already included)
- [ ] Requires environment variable: `NEW_PAYMENT_TIMEOUT_MS`
## Screenshots (if UI change)
<!-- Before / After screenshots help reviewers understand intent -->
## Checklist
- [ ] PR is < 400 lines of meaningful change
- [ ] Tests cover the happy path and at least one failure case
- [ ] No console.log or debug code left in
- [ ] Documentation updated if behavior changed
The 400-Line Rule
PRs larger than 400 lines of non-generated code are consistently reviewed poorly. Research from Google's engineering teams shows review quality drops sharply above 200 lines and becomes essentially ceremonial above 400.
When a feature requires 2,000 lines of change, break it into a series of PRs:
PR 1: Add new domain model (no behavior change)
PR 2: Add new service with feature-flagged routing
PR 3: Migrate controller to new service (flag: 0% traffic)
PR 4: Enable flag for 5% of users
PR 5: Clean up old code after full rollout
Code Review Standards
Good code reviews have a consistent vocabulary. Use these conventions:
nit: Minor style issue, doesn't block merge
nit: Could use a more descriptive variable name here
question: Clarifying question, not blocking
question: Why did you choose HashMap over LinkedHashMap here?
suggestion: Non-blocking improvement
suggestion: Consider extracting this to a utility method
issue: Blocking concern — must be addressed before merge
issue: This doesn't handle the null case from getUserById()
BLOCKING: Critical issue — security, correctness, or data integrity
BLOCKING: This SQL query is vulnerable to injection via userId param
The rule: Reviewers must distinguish between "I'd do it differently" (non-blocking) and "this is wrong" (blocking). Conflating them creates frustrating review cycles that slow the team without improving quality.
The Hotfix Procedure
At 3:00 AM, production is down. You need to ship a fix without your normal review process. Here is the safe way:
Step 1: Create hotfix branch directly from main (not develop)
git checkout main && git pull
git checkout -b hotfix/payment-null-pointer-fix
Step 2: Make the smallest possible fix
# Fix ONLY the bug. Do not refactor. Do not add features.
Step 3: Get ONE reviewer (the on-call lead or most senior available)
# Async Slack message with a link to the diff
# If no reviewer available within 15 minutes: proceed with compensating control
Step 4: Deploy via the standard pipeline
# Merge to main → triggers CI → deploys to staging → deploy to production
# DO NOT skip CI even in an incident
Step 5: Compensating controls if no reviewer was available
# - Post the diff in the incident Slack channel
# - Assign a post-incident review ticket for the next business day
# - Add a comment to the PR: "Emergency hotfix — reviewed async by @oncall"
Step 6: After the fix: backport to develop (if using Git Flow)
git checkout develop
git cherry-pick <hotfix-commit-hash>
The critical rule: The hotfix procedure exists so you have a safe path to move fast in an incident. But skipping CI — even once — creates precedent. CI is the minimum viable safety net.
Automated Branch Hygiene
Stale branches are noise. Add this GitHub Action to automatically clean up merged branches:
# .github/workflows/cleanup-branches.yml
name: Cleanup Merged Branches
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Delete merged branch
uses: actions/github-script@v7
with:
script: |
const ref = context.payload.pull_request.head.ref;
// Don't delete protected branches
if (['main', 'develop', 'staging'].includes(ref)) return;
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${ref}`
});
Add a weekly audit to catch anything that slipped through:
# .github/workflows/stale-branch-report.yml
name: Weekly Stale Branch Report
on:
schedule:
- cron: '0 9 * * MON'
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: List stale branches
run: |
# Branches not updated in 30 days
git for-each-ref --format='%(refname:short) %(committerdate:relative)' \
refs/remotes/origin/ | \
awk '$2 > 30 {print}' # pseudocode — use date arithmetic in practice
Interview: The Merge Conflict Scenario
Interviewer: "Walk me through how you'd handle a major merge conflict between two teams that have been working independently for 2 weeks."
Strong Answer:
"First, I'd run git merge-base feature-a feature-b to find the common ancestor commit. This tells me how far the branches have diverged. If it's a 2-week divergence, I'd expect significant conflicts.
Before touching any code, I'd open a quick 30-minute call with the leads from both teams. Understanding the intent behind each change is more important than the syntax. Often, what looks like a conflict at the code level isn't actually a logical conflict.
Then I'd prefer to rebase rather than merge where possible. I'd have the team with the simpler changes rebase onto the team with more complex changes. git rebase -i lets me squash intermediate commits first, which makes the conflicts cleaner.
For the actual conflict resolution, I'd use git checkout --ours or --theirs for any files where one team's change is clearly the right one. For files with genuine conflicts, I'd resolve them line by line, ensuring the final result is logically coherent rather than just syntactically valid.
Finally, I'd never declare 'merge complete' until the full test suite passes, not just the changed files. Integration conflicts often show up in unexpected test failures."
Production Readiness Checklist
Before going live with a new Git workflow for your team:
- Branch protection enabled on
mainwith admin enforcement - CODEOWNERS file covers all security-sensitive and payment-critical paths
- PR template is in
.github/pull_request_template.md - Automated branch cleanup is configured
- Hotfix procedure is documented and the team has practiced it once
- CI/CD pipeline triggers on every merge to
main - Feature flag system is available for merging incomplete work safely
Key Takeaways
- Trunk-based development with short-lived feature branches is the standard for high-velocity teams; Git Flow adds overhead that slows deployment frequency.
- Branch protection rules + CODEOWNERS eliminates entire classes of accidental main branch corruption.
- A hotfix procedure that skips the normal review cycle is always needed — but must have compensating controls.