A branching strategy is a team's agreement about where work happens, how long it may stay apart from everyone else's work, and how it reaches users. Git itself does not care: a branch costs forty-one bytes. What costs real money is the time between writing a change and integrating it with everyone else's, because every hour of separation is an hour in which conflicts, broken assumptions and untested combinations pile up.

This article explains the common strategies from first principles rather than as rival brands. It starts with what Git actually stores, names the forces that should drive the choice, walks through trunk-based development, GitHub flow, Gitflow and release branches, compares merge, squash and rebase, then works through a real migration with commands. It ends with guardrails, failure modes and a checklist you can apply this week.

Advertisement

What a branch actually is

In Git a branch is a file containing a commit hash. Commits form a directed acyclic graph in which every commit points at its parents; a branch name is a movable label on one node, and HEAD says which label you are working on. Creating a branch copies nothing. Committing on it moves the label forward. Merging creates a commit with two parents, or simply slides the label forward when no divergence exists, which Git calls a fast-forward.

Two consequences matter for strategy. First, branches are cheap to create and delete, so the cost of a strategy is never the number of branches but how long they live. Second, the amount of work in a merge depends on how far the two sides have moved since their merge base, the most recent common ancestor. You can see that distance directly:

git merge-base main feature/search-ranking          # the common ancestor
git rev-list --count main..feature/search-ranking   # commits only on the feature
git rev-list --count feature/search-ranking..main   # commits it has not seen yet
git diff --stat $(git merge-base main HEAD) HEAD     # what this branch changes

If the third number is in the hundreds, the branch is integrating with a codebase that no longer exists. That number, not the name of the workflow, is the thing to watch.

The forces that decide a strategy

Every strategy is an answer to a few questions. Teams that pick a workflow by fashion usually discover they answered them wrongly.

  • How often do you release? A web service deployed many times a day needs one always-releasable line. An app that ships to a store every few weeks, or firmware that ships quarterly, needs a way to stabilise a release while new work continues.
  • How many versions do you support at once? If customers run 3.x and 4.x and both get security fixes, you need maintenance lines. If everyone runs the latest deploy, you do not.
  • How fast and trustworthy is CI? Frequent integration only works when the build and tests on the main line run in minutes and fail for real reasons. Slow or flaky CI pushes teams towards batching, which pushes them towards long branches.
  • Can unfinished work be hidden? Feature flags, branch by abstraction and dark launches let incomplete code live on the main line safely. Without them, the only place to hide unfinished work is a branch.
  • Who must approve what? Regulated changes may need review records and release sign-off, which shapes where gates sit, not necessarily how many branches exist.
Trunk-basedmainshort-lived branches, merged within a day or tworelease/2.4 (cut, then cherry-pick fixes)Gitflowmaindevelopfeature/*feature lives for weeksrelease/*tag v2.4.0hotfix/*Every extra long-lived line is another place where changes wait to be integrated.
Trunk-based development keeps one integration line and cuts short release branches when needed; Gitflow keeps two permanent lines plus feature, release and hotfix branches.
Advertisement

Trunk-based development

In trunk-based development everybody integrates into one branch, usually main, at least daily. Small teams may commit directly; most teams use short-lived branches that exist for hours or a day or two and merge through a pull request with required checks. The main line is kept releasable at all times, and releases are either deployed straight from it or cut as tags or short release branches.

The discipline it demands is in the code, not the repository. Large changes are split into small, independently safe steps. Work that is not ready for users ships dark behind a flag, which is why feature flags and trunk-based development are usually adopted together. Interfaces are changed with expand-and-contract: add the new path, migrate callers, remove the old path, each as its own merge.

The payoff is that conflicts are small because nobody is far from main, CI tests the combination that will actually ship, and the path from commit to production is one straight line that a CI/CD pipeline can automate end to end. The cost is that a broken main blocks everyone, so the team must invest in fast pre-merge checks, a merge queue where traffic is high, and a habit of reverting first and debugging second.

GitHub flow

GitHub flow is trunk-based development described in pull-request terms: branch from main, commit, open a pull request, discuss and review, deploy or test from the branch if needed, merge, and delete the branch. There is one permanent branch. Everything else is a topic branch whose whole life is one reviewable change.

It fits teams that deploy continuously and support one version. Its weak spot: if pull requests grow for weeks, GitHub flow quietly becomes long-lived feature branching. A review rule of thumb such as "small enough to review in one sitting" does more for integration speed than any naming convention.

Gitflow and why it is less common now

Gitflow, described by Vincent Driessen in his 2010 post "A successful Git branching model", keeps two permanent branches. main holds only released code, one commit per release, tagged. develop collects finished features. Feature branches start from develop and merge back with --no-ff so the feature stays visible as a unit. When develop is ready, a release/* branch is cut for stabilisation; only fixes go there, and when it ships it is merged into both main and develop. Urgent production fixes start from main as hotfix/* and are also merged into both.

The model was designed for software with explicit, versioned releases, and for that it still works: it separates "what is in production" from "what is next" and gives stabilisation its own space. Its costs are the double merges, the extra permanent line where integration problems hide, and feature branches that naturally live for weeks. In a note added to the post in 2020, Driessen himself recommended a simpler workflow such as GitHub flow for software that is delivered continuously, and kept Gitflow for explicitly versioned software or several versions in the wild.

Release branches without Gitflow

You do not need Gitflow to stabilise releases or maintain old versions. The common pattern is release branches cut from trunk: development continues on main; at release time you cut release/2.4 from a known-good commit; fixes land on main first and are cherry-picked onto the release branch; the branch is tagged for each patch release and abandoned when the version reaches end of life.

# cut the release from a green commit on main
git switch -c release/2.4 3f9c2ab
git push -u origin release/2.4
git tag -a v2.4.0 -m "2.4.0" && git push origin v2.4.0

# a bug is fixed on main first, then backported with provenance
git switch release/2.4
git cherry-pick -x 8d41e07        # -x records "(cherry picked from commit 8d41e07...)"
git tag -a v2.4.1 -m "2.4.1" && git push origin release/2.4 v2.4.1

Fix on main first, then backport. The opposite order, fixing on the release branch and promising to merge it back later, is the most common way a fixed bug comes back in the next release. The -x trailer makes backports auditable: git log --grep 'cherry picked from' on the release branch lists them. For a fixed cadence of such branches, see release trains.

Merge, squash or rebase

Separate from where branches live is how their commits arrive on main. All three options produce the same final tree; they differ in the history they leave.

MethodHistory on mainGood forWatch out for
Merge commit (--no-ff)Branch commits plus a merge commit with two parentsKeeping a feature visible as a unit; GitflowNoisy graph; work-in-progress commits reach main
Squash mergeOne new commit per pull requestSmall PRs, readable linear history, easy revertLoses internal commits; branch must be deleted, not reused
Rebase and fast-forwardBranch commits replayed on top, linearCurated commit series where each commit buildsRewrites hashes; never rebase a branch others have pulled

For trunk-based teams with small pull requests, squash merging is the simplest default: one commit per reviewed change, and git revert undoes exactly one change. Teams that write careful commit series prefer rebase-and-merge. Whatever you choose, enforce it in repository settings rather than by convention, so history stays consistent.

Rebasing your own branch onto fresh main before review is a different matter and is usually good practice: git fetch origin && git rebase origin/main keeps the diff honest. Push the result with git push --force-with-lease, which refuses to overwrite commits on the remote that you have not seen, rather than a bare --force.

Worked example: moving a team off Gitflow

A team of nine runs Gitflow for a web service that deploys weekly. Symptoms: feature branches average eleven days, the Thursday merge into develop routinely breaks the build, the release branch needs two days of fixes, and hotfixes sometimes never reach develop. They want to deploy daily.

Step 1: make main the only integration line. Merge develop into main one last time, protect main, and stop creating feature branches from develop. Keep develop read-only for two weeks so nothing is lost, then delete it.

git switch main && git pull
git merge --no-ff origin/develop -m "Retire develop: main is now the integration branch"
git push origin main
# later, once nothing references it
git push origin --delete develop

Step 2: shrink the work, not just the branches. Open features are split into flagged slices. The search-ranking feature, eleven days old, becomes three pull requests: a new scoring interface with the old implementation behind it, the new implementation behind a flag defaulting to off, and the flag flip, which is a configuration change rather than a merge.

Step 3: add guardrails. Branch protection on main requires one review and the CI checks; a merge queue tests each pull request against the latest main plus anything ahead of it; squash merge is the only allowed method; branches are deleted automatically on merge.

Step 4: release from main. Each green commit on main is deployable; the pipeline tags deploys. A release branch is cut only if a deploy must be patched without taking newer changes, and fixes reach it with cherry-pick -x.

Step 5: measure. The team tracks branch age at merge, time from first commit to production, how often main is red and for how long, and change failure rate. A month in, the sign of success is branch age measured in hours and a red main reverted within a session.

Guardrails that make any strategy work

  • Protected branches. Block direct pushes and force-pushes to main and release branches; require status checks and review.
  • Merge queue. With many merges a day, two pull requests can each pass against an old main and break it together. A queue tests them in order against the real future state.
  • Fast, reliable checks. Keep the required pre-merge suite to minutes; move slow suites to post-merge with automatic revert or alerting. Quarantine flaky tests rather than retrying them silently.
  • Code ownership. A CODEOWNERS file routes reviews to the right people without a separate integration branch per team.
  • Flags with expiry. Every flag gets an owner and a removal date, or trunk fills with dead branches of a different kind. The feature flag guide covers lifecycle.
  • Naming and cleanup. Prefix branches by type and owner, delete on merge, and run a weekly report of branches older than a few days.

Failure modes

SymptomCauseFix
Merge day breaks the buildLong-lived branches integrate rarelyDaily integration; flags for unfinished work
Fixed bug reappears next releaseFix made on a release branch, never merged backFix on main first, cherry-pick -x to the release
Main is red for hoursSlow or flaky checks; no revert habitMerge queue, quarantine flakes, revert first
Teammate's commits vanishForce-push over a shared branchProtect branches; use --force-with-lease on own branches only
Environment branches driftBranches named dev, staging, prod promoted by mergeOne artifact promoted through environments by the pipeline
Flag graveyardFlags never removed after launchOwner and expiry per flag; removal as part of done
Unreviewable PRsBranch scope set by feature, not by changeSplit into safe steps; expand and contract

The environment-branch anti-pattern deserves emphasis. Promoting code by merging dev into staging into prod means each environment runs a build that was never tested anywhere else. Build once from main and promote the same artifact; environments differ in configuration, not in source.

Choosing

SituationRecommendedWhy
Web service, continuous deploy, one versionTrunk-based or GitHub flowShortest path from commit to production
Mobile or desktop app with store releasesTrunk plus short release branchesStabilise each release while main moves on
Several supported major versionsTrunk plus long-lived maintenance branchesBackports with cherry-pick -x
Explicitly versioned product, infrequent releases, mature team on GitflowGitflow is acceptableIts structure matches the release process
Open source with outside contributorsFork plus pull request into mainContributors cannot push; maintainers integrate

Branching for data and models follows the same logic with different storage; data versioning shows how branches over an object store behave.

What to do next

  1. Measure your current median branch age at merge and the commits-behind count for open branches; that is your real strategy, whatever the wiki says.
  2. Write down release cadence and number of supported versions, then pick from the choosing table.
  3. Protect main: required review, required checks, no force-push, one allowed merge method, auto-delete merged branches.
  4. Get the required pre-merge checks under ten minutes and quarantine flaky tests.
  5. Introduce feature flags with owners and expiry dates before shortening branches, so unfinished work has somewhere safe to live.
  6. Adopt the rule that fixes land on main first and are backported with cherry-pick -x.
  7. Review the numbers after a month and shorten again.
Key takeaway: A branch is a pointer; the strategy is really a policy on how long work may stay unintegrated. Continuous-delivery teams do best with trunk-based development or GitHub flow, short branches, feature flags and a protected, always-green main. Teams that ship versioned releases add short release branches and backport fixes from main with cherry-pick -x. Gitflow still fits explicitly versioned products, but its extra long-lived lines are where integration pain hides.