Advanced Workflows

Git Flow vs trunk-based development, their trade-offs, and feature flags as an alternative to long-lived branches.

Branching strategy is a team decision, not just a Git feature

Branching and Merging and Rebasing and History covered the mechanics of branches, merges, and rebases. Neither answers a genuinely important organizational question: what should a team's branches themselves actually represent, and for how long should they live before merging back? Two well-known answers — Git Flow and trunk-based development — sit at opposite ends of that spectrum, and the choice has real consequences for how often code integrates and how painful merges tend to be.

Git Flow

Git Flow (formalized by Vincent Driessen in 2010) defines a strict set of long-lived branches, each with a specific purpose:

Plaintext
main        ─────●───────────────────────●─────────  (production releases only)
                   \                     /
release/1.2.0       ●───●───────────────●            (stabilizing a release)
                    /                    
develop     ──●────●────●────●────●────●────●──────  (integration branch)
                \        \        /    /
feature/a        ●───●───●       /    /
feature/b                 ●──●──●    /
hotfix/1.1.1                        ●──●             (urgent production fix)
  • main — always reflects exactly what's in production; nothing merges here except finished releases and hotfixes.
  • develop — the ongoing integration branch where finished features accumulate between releases.
  • feature/* — one branch per feature, branched from develop, merged back into develop when done. These can live for days or weeks.
  • release/* — cut from develop when preparing a release; only bug fixes land here while it stabilizes, then it merges into both main (tagged as a release) and back into develop.
  • hotfix/* — branched directly from main to patch a production issue urgently, merged into both main and develop.

What it gets you: a very clear, structured process — anyone can tell at a glance what a branch is for, and main is guaranteed to always be exactly what's deployed. It suits products with genuinely scheduled, versioned releases (installed desktop software, embedded firmware, anything with a slow, deliberate release cadence) where "release 2.3.0" is a real, meaningful, long-lived artifact.

Where it breaks down: feature branches that live for days or weeks drift further from develop every day they're open, and the eventual merge gets progressively more likely to conflict. For a web application deploying continuously — sometimes many times a day — the whole release/* branch concept adds process with little payoff, since there's no meaningfully distinct "release" separate from whatever is currently on main.

Trunk-based development

Trunk-based development flips the default: everyone works directly against one shared branch (main, the "trunk"), and feature branches, if they exist at all, live for hours, not weeks, before merging back.

Plaintext
main   ──●──●──●──●──●──●──●──●──●──●──●──●──●──────
          \  /    \  /    \  /       \  /
           ●●       ●●      ●          ●●          (short-lived branches,
        (few hours)(a day) (same day) (few hours)    each merged same-day)

Every merge to main is expected to be deployable — enforced by CI running on every merge, not by a separate stabilization branch. Work that isn't finished yet, but whose code has already merged, is hidden behind a feature flag (covered below) rather than kept isolated on a long-lived branch.

What it gets you: integration happens constantly, in small increments, so conflicts are small and easy to resolve — the opposite of a branch that's drifted for three weeks and now touches half the files another branch also touched. It's the default at most companies practicing continuous deployment, because "always shippable" and "always merged" turn out to be nearly the same property.

Where it breaks down: it demands real CI discipline (a broken main blocks everyone, immediately) and a genuine cultural commitment to small, incremental changes rather than one sprawling branch for an entire feature — teams that adopt the label without the discipline often end up with a main that's frequently broken.

Comparing the two

Git Flow Trunk-based development
Branch lifetime Days to weeks (feature branches), longer for release branches Hours to a day or two, if branches are used at all
main always deployable Only after a release branch stabilizes Yes, enforced continuously by CI
Merge conflict risk Higher — branches drift further before merging Lower — small, frequent merges
Unfinished work in progress Isolated on its own branch until ready Merged behind a feature flag, hidden from users
Best fit Scheduled, versioned releases (desktop software, firmware) Continuous deployment (most modern web/SaaS products)
Process overhead Higher — explicit release/hotfix branch types Lower — mostly just main and short branches

Feature flags as an alternative to long-lived branches

A feature flag (or feature toggle) is a runtime condition — usually a config value or a call to a flag-management service — that decides whether a piece of already-merged code actually executes:

PHP
if (Feature::enabled('new-checkout-flow', $user)) {
    return $this->renderNewCheckout($order);
}

return $this->renderLegacyCheckout($order);

This is the mechanism that makes trunk-based development practical for genuinely large or risky features: the code for a half-finished "new checkout flow" merges into main continuously, in small reviewable pieces, without ever being reachable by real users, because the flag is off in production. When the feature is actually ready, flipping the flag — not a giant merge — is what makes it live, and if a serious problem is found in production, flipping it back off is a much faster mitigation than reverting a large merge under pressure.

Feature flags and long-lived feature branches are solving the same underlying problem — "let unfinished work exist without breaking things for everyone else" — with an important difference in where the unfinished-ness lives:

Long-lived feature branch Feature flag
Unfinished code lives... Outside main, isolated until merge Inside main, merged but inactive
Integration happens All at once, at merge time (the "merge day" everyone dreads) Continuously, in small increments
Turning a feature on/off Requires a merge (and possibly a deploy) A config change, often instant, no deploy needed
Rollback if something's wrong Revert the merge commit Flip the flag off

Flags aren't free, either — a codebase with dozens of stale, forgotten flags accumulates branching logic that never gets cleaned up, which is its own form of technical debt; a flag should have an owner and an expected removal date once the feature fully ships.

Common mistakes

  • Adopting Git Flow's full branch taxonomy (develop, release/*, hotfix/*) for a product that deploys continuously, adding real process overhead with little corresponding benefit.
  • Calling a workflow "trunk-based" while still letting feature branches live for weeks — the benefit of trunk-based development comes specifically from short-lived branches and frequent integration, not from the name.
  • Introducing feature flags without ever removing them once a feature is fully shipped — a codebase can accumulate dozens of permanently-on flags that nobody remembers the purpose of, which is just long-lived-branch complexity relocated into runtime conditionals.
  • Assuming trunk-based development means no branches at all — it typically still uses short-lived branches for individual changes; the defining trait is how quickly they merge back, not their complete absence.