The Branch Is Not the Feature

Most branching strategy debates are actually feature flag debates in disguise. The real question is: how do you ship code that's in progress without it breaking production? The answer is almost never β€œmore branches” β€” it's separating deployment from release. This article compares the main strategies and tells you which one fits which team.

πŸ€” Sound familiar?
  • Your main branch has a 2-week-old feature branch diverging from it and someone's about to have a bad merge day
  • You use GitFlow because you read about it in 2018 and never questioned it
  • Every PR requires a β€œrebase on main” step that takes 30 minutes
  • Your β€œrelease branch” is permanently behind main but everyone's afraid to delete it

Trunk-based development with feature flags lets a 20-person team deploy 10x per day without coordination overhead.

Trunk-Based Development

Everyone commits to main(the trunk) directly, or via short-lived branches that live <2 days. Continuous integration is the literal definition: code is always integrated. You ship behind feature flags, not behind branches.


gitGraph
    commit id: "fix: input validation"
    branch feature/payment-v2
    commit id: "feat: payment behind flag"
    checkout main
    merge feature/payment-v2 id: "merge (1 day old)"
    commit id: "fix: typo"
    commit id: "chore: deps update"
    branch fix/auth-edge-case
    commit id: "fix: token refresh"
    checkout main
    merge fix/auth-edge-case id: "merge (4 hours old)"
// Feature flag approach β€” ship incomplete code safely
// The flag controls visibility, not deployment

import { isFeatureEnabled } from '@/lib/flags';

export async function getCheckoutFlow(userId: string) {
  const useNewPaymentFlow = await isFeatureEnabled('payment-v2', { userId });
  
  if (useNewPaymentFlow) {
    return newPaymentFlow(); // In development β€” only internal users see this
  }
  return legacyPaymentFlow(); // Everyone else
}

// Flags can be:
// - Static (env var: PAYMENT_V2_ENABLED=true)
// - User-based (LaunchDarkly, GrowthBook, Unleash)
// - Percentage rollout (5% β†’ 25% β†’ 100%)
// - Kill switch (disable instantly if metrics degrade)

GitFlow

GitFlow uses long-lived branches: main, develop, feature/*, release/*,hotfix/*. It was designed for versioned software with scheduled releases β€” desktop apps, SDKs, firmware. For continuously-deployed web services, it adds coordination overhead without the benefits.

# GitFlow branch structure
main          ─────────────────────────────────────────────────────▢
                    ↑ (merge from release/1.2.0)    ↑ (hotfix)
release/1.2.0       ──────────────────────────
develop       ─────────────────────────────────────────────────────▢
                  ↑ (merged features)
feature/A         ────────
feature/B               ──────────────

# When GitFlow makes sense:
# - Versioned libraries (npm packages, SDKs)
# - Mobile apps with App Store review cycles
# - Enterprise software with quarterly releases
# - Regulated software requiring audit trails per version

# When it doesn't:
# - SaaS web applications (continuously deployed)
# - Internal tools
# - Any team deploying more than once per week

Merge Strategies

# Three merge strategies β€” each has different implications for git history

# 1. Merge commit β€” preserves full branch history
git merge feature/my-feature
# Result: creates a merge commit, full history visible
# Pro: complete context
# Con: noisy history with many small commits

# 2. Squash merge β€” one commit per feature
git merge --squash feature/my-feature
git commit -m "feat: add payment v2 flow (#123)"
# Result: single commit on main
# Pro: clean linear history
# Con: individual commits lost, harder to bisect

# 3. Rebase β€” replay commits on top of main
git rebase main feature/my-feature
git checkout main && git merge feature/my-feature
# Result: linear history, no merge commits
# Pro: clean history, each commit meaningful
# Con: rewrites SHA β€” problematic for shared branches

# Recommendation for trunk-based development:
# Use squash merge for feature branches (clean main history)
# Use conventional commits format for PR titles:
# feat: user authentication (#145)
# fix: token expiry edge case (#146)
# chore: upgrade to Node 20 (#147)

Branch Protection Rules

# GitHub repo settings (or Terraform equivalent)
# Settings > Branches > Branch protection rules > main

branch_protection_rule:
  pattern: "main"
  requires:
    - status_checks:
        - ci/lint-typecheck
        - ci/test
        - ci/build
      # These must be green before merge
    
    - pull_request_reviews:
        required: 1          # At least 1 approval
        dismiss_stale: true  # Re-approve after new pushes
    
    - linear_history: true   # Enforce squash or rebase β€” no merge commits
    
    - no_force_push: true
    - deletion_protected: true

Handling Hotfixes in Trunk-Based Development

# In trunk-based development, hotfixes go through the same process
# but can be fast-tracked:

# 1. Create a short-lived branch from main (which IS production)
git checkout -b fix/critical-auth-bypass main

# 2. Apply fix, test locally
git commit -m "fix: prevent token reuse after logout (#emergency)"

# 3. PR with emergency label β€” triggers expedited review
# CI still runs (non-negotiable β€” hotfixes cause incidents too)

# 4. Squash merge to main β†’ auto-deploys to production
# No cherry-picking needed β€” main IS the release branch

# vs GitFlow hotfix (more complex):
# git checkout -b hotfix/1.2.1 main
# ... fix ...
# merge to main AND to develop AND to release/1.2.0
# tag 1.2.1
# (three merges for one fix)

Pitfalls

Feature flags without cleanup

Every feature flag is technical debt. Flags that never get removed become permanent conditional paths that nobody fully understands. Create a ticket to remove every flag when you create it. Set a calendar reminder. Flags older than 3 months are almost certainly dead code waiting to cause a bug.

Trunk-based without CI

Trunk-based development only works if your CI pipeline is fast (<10 minutes) and reliable (<1% flake rate). Committing to trunk frequently with a slow or flaky CI means you're constantly breaking the build for everyone. Fix the pipeline before adopting TBD.

Long-lived feature branches in TBD

A β€œfeature branch” that lives for 2 weeks is not trunk-based development β€” it's waterfall with extra steps. If a feature is too big to ship in 1-2 days, decompose it. Ship the data model, then the API, then the UI, all behind a feature flag that only enables the complete experience.