DevFox Labs
HomeLearningToolsAboutContact
intermediate35 minby DevFox

Branching Models for Teams

GitHub Flow, trunk-based development and Git Flow compared honestly, plus release and hotfix branches, the real cost of long-lived branches, and how to keep one current.

  • Git
  • DevOps

On this page

  • Git has no opinion about any of this
  • GitHub Flow: one trunk, short-lived branches
  • Trunk-based development and the cost of flags
  • Git Flow, and the software it was built for
  • Release branches and hotfixes as a pattern
  • Why long-lived branches hurt so much
  • Keeping a branch current: merge or rebase
  • Naming, and the hygiene that goes with it
  • What to pick
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    Two teams, same Git. One merges into main a dozen times a day and has never cut a release branch. The other has a permanent develop, a release/4.2 that only three people may touch, and a written rule about hotfixes. Neither is doing Git wrong.

    By the end of this lesson you will be able to read any team's branching model as a set of trade-offs — what it buys, what it costs, the cadence it assumes — and pick or change one on purpose rather than inherit it.

    Git has no opinion about any of this

    Git gives you commits, branches, and merges. That is the whole vocabulary. It will let you commit straight to main, keep a branch alive for a year, or delete a branch nobody reviewed — and "never push to main" exists nowhere inside it. Rules like that are a branching model: a convention a team agreed to and now enforces with tooling, review, and habit.

    A branch is one line in .git/refs/heads/ holding a commit ID, so a model's cost is never technical — it is all human agreement.

    Which means the question that decides your model is not "what is best practice." It is how does this software reach its users? Deploy from trunk several times a day and you need cheap integration. Ship 4.2 to customers who upgrade when they feel like it while you still owe 4.0 a security fix, and you need a model that holds three live versions.

    GitHub Flow: one trunk, short-lived branches

    This is the modern web default, the one to assume unless told otherwise. main is always deployable; every change is a branch off it, lives hours or days, and is deleted the moment it merges back.

    text
    main      A---B---------------M---N
                   \             /
    feat/totals     C---D---E---'

    It optimises for having one thing to reason about: one answer to "what is in production," one place a change can regress, one merge to revert.

    It hurts twice. It has no answer for more than one released version — that is the release-branch pattern below — and it degrades silently, because nothing enforces "short-lived": a branch that quietly turns three weeks old is still nominally GitHub Flow while paying every cost of a long-lived one.

    It also rests on a precondition people skip: "main is always deployable" is a claim only your test suite can back. If you would not ship a green build without a human reading the diff, main is usually deployable, not always.

    Trunk-based development and the cost of flags

    Trunk-based development takes the same shape and tightens the clock. Branches live hours, not days, and everyone integrates into main at least once a day. Unfinished work still ships to production, unreachable behind a feature flag — a runtime conditional keyed on configuration you can change without a deploy. The payoff is integration: hours of divergence rarely collide, for reasons the next section makes precise, and when production breaks the suspect change is minutes old.

    text
    main   A--B--C--D--E--F--G--H--I    merges land all day
              \_/  \_/     \_/  \_/     branches are hours old,
                                        unfinished screens ship
                                        dark behind flags

    Flags are usually sold without a price tag, so here it is. A flag is a branch in your code that Git cannot see, and it is not deleted when it merges. Two flags mean four paths through that screen; ten mean nobody is testing the combination real users are in, and the losing side of each is dead code that still gets refactored by people who do not know it is dead.

    Worse, adding a flag is somebody's ticket and removing one is nobody's, so flags outlive the experiment, then the feature, then the person who added them. Record an owner and a removal date when you create one, and schedule the cleanup.

    Make a stale flag findable

    Ask Git rather than read code when you need to know whether a flag still does anything. git log -S "flags.newCheckout" shows every commit that added or removed that string, so you see the flag's whole life. One whose last commit is a year old is a cleanup ticket.

    Git Flow, and the software it was built for

    Git Flow is the heavyweight: two permanent branches and three temporary kinds. develop is the integration line, feature/* branches off it, release/* is cut from develop once a version is scoped and takes only stabilisation work, hotfix/* comes off main for emergencies, and main holds nothing but tagged releases.

    text
    main       A-------------T1-----------T2   (tagged releases only)
                \             /           /
    release/1.1  \      R1---'           /
                  \    /                /
    develop    D1--D2--D3---D4---D5---D6
                 \    /       \    /
    feature       F1-'         F2-'

    Every release branch merges into main to be tagged and back into develop so its fixes are not stranded; every hotfix does the same. The point of that traffic is a stabilisation window: once release/1.1 is cut, develop stays open for the next version while a named group takes 1.1 to shippable with nobody's new work landing underneath them.

    The cost is proportionate: every change crosses at least two merges before release, "what is live" gains two answers — the tag on main and the tip of develop — and a frozen release branch piles work onto develop, so the merge back is the largest your team performs.

    Who it is for: software you cut versions of and hand over — desktop and mobile apps, firmware, on-premises products, anything with a compliance sign-off. Its author added a note in 2020 saying it is the wrong model for software delivered continuously, which is the polite reply when someone calls it the professional default.

    Release branches and hotfixes as a pattern

    You do not have to adopt Git Flow to use its best idea. You need the release branch the first time someone reports a bug in 2.3 while main has moved on to 2.5.

    text
    main         A---B---C---D---E---F---H'   H' = same fix, new commit
                  \                      ^
                   \                     | cherry-pick
                    \                    |
    release/2.3      o---------------H---'    tagged v2.3.1
                     (branched at tag v2.3.0)

    Branch from the tag, not from main: you want the code as it shipped plus one fix, nothing else.

    bash
    git switch -c release/2.3 v2.3.0   # branch off the released tag
    git switch -c hotfix/token-expiry  # do the work on its own branch

    Merge the hotfix into release/2.3, tag the patch release, ship. Then the step teams forget: get the fix onto trunk. A release branch is a dead end, and a fix that lives only there is the same bug again in 2.6.

    bash
    git switch main
    git cherry-pick <sha-of-the-fix>   # replay the one commit

    Cherry-pick rather than merge, for a specific reason: a release branch carries commits you do not want on trunk — a version bump, a feature reverted to calm the release, a patch vendored for one customer. Merging drags all of it along and ties a dead-end line of history to main for good. Cherry-picking takes only the commit you meant, as a new commit with a new ID; lesson 5 covers what it means for conflicts and for -x.

    When the fix is not urgent, reverse the order: land it on main first, then cherry-pick onto the release branch. Trunk must never lose a fix, so it should get it first.

    Check what has not been ported

    git cherry -v main release/2.3 marks every commit on the release branch + when it is not in main and - when an equivalent change is. It compares patches rather than commit IDs, so cherry-picked commits are recognised despite their new SHAs. Run it before closing a release.

    Every live release branch is somewhere each security fix must be applied and tested, so how many you keep is a support commitment: decide a version's end-of-life date when you cut it.

    Why long-lived branches hurt so much

    Every model above is an argument about one number, and here is why it matters. The cost of merging grows faster than the time you spend apart: conflicts happen where two sets of edits overlap, and both sets keep growing while you are away, so the chance of collision tracks roughly the product of the two, not the sum. Two days out, you conflict over an import list. Six weeks out, main has been refactored underneath you — the function you edited is renamed and split in two, and the API you call takes a context object it did not have when you started.

    That is not a Git problem.

    The merge that conflicts

    Two designs, not two lines.

    Git shows a text conflict and asks you to choose. The real decision is which of two incompatible ideas about the code survives — made by the branch author alone, under pressure to land.

    The merge that is perfectly clean

    The dangerous one.

    Your code compiles, calls a function whose behaviour changed while you were away, and ships a bug no tool could have flagged.

    Nothing asked you anything.

    The visible half is the survivable one

    So measure divergence rather than guess at it:

    bash
    git fetch origin
    git rev-list --left-right --count origin/main...HEAD
    # prints two numbers: commits only on main, commits only on yours
    git diff --stat origin/main...HEAD   # how big your branch has got

    Then pick a limit and say it out loud — "no branch goes a working day without trunk merged into it" — because a rule nobody stated is a rule nobody follows.

    Stop resolving the same conflict twice

    Run git config --global rerere.enabled true once. Git records how you resolved each conflicted hunk and replays that resolution the next time the identical conflict appears. On a branch you rebase repeatedly, the fourth encounter with the same hunk then costs nothing instead of ten minutes.

    Keeping a branch current: merge or rebase

    Branches have to catch up often. The two moves differ less in the result than in who else pays for them.

    bash
    git fetch origin
    git merge origin/main    # bring main's commits into your branch
    # or
    git rebase origin/main   # replay your commits on top of main

    Merging is additive. Your commits keep their IDs and a merge commit records the catch-up, so nothing a colleague already has is invalidated. The cost is cosmetic but real: the log fills with "Merge branch 'main' into feat/x" entries, and reading what the branch changed means git diff main...HEAD rather than its history.

    Rebasing rewrites. It does not move your commits — it creates new ones with the same changes and abandons the originals, which survive only in the reflog until it expires (lesson 9). You get a branch that looks like you started this morning, a straight line on the current tip. You pay git push --force-with-lease and the fact that every commit ID has changed.

    The work differs too. A merge resolves each conflicted region once; a rebase replays your commits one at a time, so the same region can conflict once per commit. That is why a long rebase through a busy area is a slog, and why rerere pays for itself here first.

    Hence the rule, which is about people rather than history: rebase while the branch is yours alone, merge once others are reading it. "Yours alone" means nobody has pulled it and nothing is anchored to its commit IDs — a CI result or a review comment pinned to a SHA counts.

    What a force-push actually destroys

    --force-with-lease refuses the push if the remote moved since your last fetch, which protects you from overwriting a colleague's push — but does nothing for a colleague who already pulled. Their branch points at commits that no longer exist, and their next git pull tries to merge two versions of the same work. Plain --force skips even the remote check.

    Naming, and the hygiene that goes with it

    A branch name has one job: let someone reading the list in six months work out who owns it, what it is, and whether it is dead. The common shapes all do that.

    text
    feat/checkout-totals       type prefix + short description
    fix/1284-token-expiry      type prefix + ticket + description
    ana/feat/checkout-totals   author prefix, common on large teams
    release/2.3  hotfix/2.3.1  reserved prefixes your tooling matches

    Which one you choose matters far less than whether everyone uses it, because consistency makes names machine-readable: your host can protect release/* and require checks on feat/*, and CI can run the fast suite on one prefix and the full one on another. Write the scheme in the README and stop debating it.

    One trap with slashes: Git stores refs as paths, so feat and feat/totals cannot coexist — the second fails with "cannot create," because refs/heads/feat would have to be both a file and a directory. Pick prefixes you will never also use as whole names.

    Hygiene is the other half: dead branches accumulate until nobody can find a live one.

    bash
    git branch --merged main             # fully merged, safe to delete
    git branch -d feat/checkout-totals   # delete a merged branch
    git fetch --prune                    # drop refs deleted on the host
    git for-each-ref --sort=-committerdate refs/remotes \
      --format='%(committerdate:short)  %(refname:short)  %(authorname)'

    Run that last one on a repository you have inherited: the bottom of the list is an archaeology of abandoned work, the top is where the team actually is.

    What to pick

    GitHub Flow

    The default.

    One trunk, short-lived branches, with an explicit limit on branch age. Right for anything you deploy rather than ship.

    Trunk-based

    Only if you will schedule flag removal.

    Feature flags are debt with a due date. A team that adds them and never removes them has swapped merge pain for a combinatorial explosion of untested code paths.

    Git Flow

    Only if users install versions.

    It was built for software with supported releases you must patch. Adopting it for a web app buys you five branch types and no benefit.

    Whichever you choose, the habits outrank the model

    The habits, whichever model you land on: branches that die within days, a releasable trunk, and release fixes that make it home.

    A cheat sheet to keep

    bash
    git switch -c feat/checkout-totals  # branch off the current trunk
    git switch -c release/2.3 v2.3.0    # release branch from a tag
    git switch -c hotfix/token-expiry   # the fix, on its own branch
    
    git fetch origin                    # refresh remote-tracking refs
    git rev-list --left-right --count origin/main...HEAD  # behind/ahead
    git diff --stat origin/main...HEAD  # how big the branch has got
    
    git merge origin/main               # catch up (others read it)
    git rebase origin/main              # catch up (branch is yours)
    git push --force-with-lease         # push after a rebase, safely
    
    git cherry-pick <sha>               # port one fix to trunk
    git cherry -v main release/2.3      # fixes not yet ported to main
    git log -S "flags.newCheckout"      # the whole life of a flag
    
    git branch --merged main            # branches safe to delete
    git branch -d feat/checkout-totals  # delete a merged branch
    git fetch --prune                   # drop refs deleted on the host
    git for-each-ref --sort=-committerdate refs/remotes  # stale branches
    
    git config --global rerere.enabled true  # reuse conflict fixes

    Where to go next

    Next, in lesson 7, is pull requests and code review: the three merge buttons and what each leaves in your history, how to update a branch without detaching the comments on it, and the host-side rules that turn the convention you chose here into something enforced.

    Before then, do some archaeology. Run the for-each-ref command above on a repository you work in and look at everything older than a fortnight, then watch the rev-list divergence count on your own branch for a week. That number climbing teaches this faster than any diagram.