Skip to content

Trunk-Based Development with Git

Lesson 5 of 8Intermediate12 min readModern Git Workflows · BranchingVerified: Git 2.43.0 on Ubuntu 24.04

Trunk-based development is a source-control practice in which every developer integrates into a single shared branch — the trunk — at least daily, and long-lived branches do not exist.

It is frequently reduced to “everyone commits straight to main”. That description is wrong often enough to be actively unhelpful. Most teams practising it use branches; the branches simply live for hours rather than weeks, and integration frequency is the thing being optimised.

The defining constraint is integration frequency, not branch prohibition.

Every developer merges their work into the shared trunk at least once a day.

Everything else follows from taking that seriously. If work must integrate daily, it must be small enough to finish or to reach a safe intermediate state daily. If it integrates daily, incomplete features must be able to sit in the trunk without being visible. If the trunk is receiving changes continuously, it must be protected by automation rather than by review latency.

Trunk-based development: many tiny branches, integrated continuously

A trunk lane labelled main with six commits in sequence. Three very short branch lanes each leave the trunk and rejoin one commit later, showing branches that exist only briefly before integration.

ABCDEFxymainBranches exist, but for hours. Divergence never has time to accumulate.

Committing directly to trunk. Developers commit and push straight to main. This works for small, highly experienced teams with strong test suites and often pair programming, which supplies review at the moment of writing. It is uncommon and demanding.

Short-lived branches. A developer branches, works for a few hours, opens a pull request, gets review and CI, and merges the same day. This is what most teams describe as trunk-based development, and it is nearly indistinguishable from GitHub Flow run with discipline about branch lifetime.

The distinction between the two matters less than the shared property: nothing diverges from the trunk for long.

The argument rests on how integration cost behaves.

When two branches diverge, the work of reconciling them depends not on how many changes each contains but on how those changes interact. Two changes to unrelated files cost nothing. Two changes to the same function may require understanding both intentions and inventing a third. As each side grows, the number of potential interactions grows much faster than the number of changes.

This is why a branch open for two weeks is not twice as hard to merge as one open for one week. It is also why “we will integrate at the end of the sprint” reliably produces a bad final day.

Trunk-based development attacks the problem at its root: if divergence never exceeds a day, interaction between changes stays small, and the expensive case never arises.

The obvious objection: some features take three weeks. How can they integrate daily?

The answer is to separate deploying code from releasing behaviour. Incomplete work merges into the trunk in a state where it is not reachable by users, and is switched on later.

if features.enabled("new_search_ranking"):
return rank_v2(results)
return rank_v1(results)

The new code path ships to production disabled. It is compiled, deployed, and covered by tests. When it is ready, a configuration change enables it — for internal users first, then a percentage, then everyone.

Simpler forms of the same idea, in rough order of complexity:

TechniqueUse for
Dead codeNew modules nothing calls yet; merge them before wiring them up
Branch by abstractionIntroduce an interface, add the new implementation behind it, switch, remove the old
Configuration flagStatic on/off per environment
Runtime feature flagToggle without redeploying; supports gradual rollout

Flags carry real costs. Each is a branch in the code, so two flags mean four possible states and the test matrix grows. Flags that outlive their purpose become permanent complexity. Teams doing this well treat flag removal as part of finishing the feature, not as cleanup to do later.

Trunk-based development is not a workflow you can adopt by changing branch names. It transfers safety from human gates to automation, and the automation has to be good enough.

A comprehensive, fast, reliable test suite. This is the load-bearing requirement. Integrating continuously means changes land without a stabilisation phase, so the tests are the stabilisation phase. They must catch most regressions, run in minutes, and — critically — not be flaky. A suite that fails randomly trains people to re-run and merge anyway, which removes the protection entirely.

Continuous integration in the literal sense. Every change, tested against the current trunk before it lands. “Requires branches to be up to date” or a merge queue closes the gap where two individually green changes break in combination.

Branch protection. Preventing direct pushes, requiring checks, and requiring the branch to be current are what make the trunk’s health structural.

Fast rollback. Something bad will reach the trunk. git revert plus an automated redeploy, measured in minutes, is what makes that survivable.

Small changes as a habit. This is cultural and the hardest part. Decomposing a fortnight of work into daily integrable pieces is a skill.

Feature flags and the discipline to remove them.

Review that keeps up. A pull request open for two days is a two-day-old branch. Teams doing this well review in hours, often by making review a scheduled interrupt rather than a background task.

The most common objection to daily integration is review latency. If review takes a day, branches cannot live less than a day. Teams practising trunk-based development resolve this in one of several ways, and which one they pick says a lot about the team.

Small pull requests reviewed quickly. The dominant approach. A fifty-line change reviewed in ten minutes does not gate integration meaningfully. This is mostly a decomposition problem, not a review problem — reviewers are slow on large changes because large changes are slow to understand.

Pair or ensemble programming. Review happens continuously while the code is written, so the commit arrives already reviewed. This is what makes committing directly to the trunk defensible for some teams.

Post-commit review. Changes land, then are reviewed shortly afterwards, with problems fixed forward. This requires high trust and strong tests, and is uncommon outside experienced teams.

Risk-tiered review. Configuration and test changes merge with light review; changes to authentication or payments require two approvals. Codified with per-path review rules rather than left to judgement.

The practices above are technical. Several of the requirements are not, and they are the ones that usually decide whether an adoption succeeds.

Reverting must be unremarkable. Someone will break the trunk. If reverting is treated as an accusation, people will instead try to fix forward under pressure, which extends the outage. Teams doing this well revert first and diagnose afterwards, and nobody takes it personally.

Trunk health has to be someone’s priority. When CI on the trunk is red, fixing it must outrank feature work. Otherwise red becomes the normal state and the signal is lost.

Test quality has to be valued in practice. Not “we agree tests are important”, but: is time spent fixing flaky tests treated as real work, or as something to do when there is slack? There never is slack, and flaky tests are what kill this workflow.

Decomposition has to be taught. Breaking a large feature into daily integrable pieces behind a flag is a learnable skill that most developers have not been taught. Expect this to take months, not a sprint.

Deployment has to be boring. If shipping requires a change-approval meeting, the trunk being continuously releasable buys you nothing.

A useful diagnostic before adopting: ask how long it takes, right now, from merging a one-line change to having it running in production, and how confident anyone is that it is safe. If the honest answer is days and “not very”, the constraint is not your branching model.

At scale, “test against current trunk” becomes a bottleneck: by the time a change finishes testing, the trunk has moved. Merge queues solve this at the platform level.

Instead of merging directly, an approved pull request joins a queue. The platform builds each entry on top of the ones ahead of it, tests that exact combination, and merges only if it passes. A failing change is ejected and the rest continue.

This is a hosting-platform feature, not Git. It is worth knowing about because it removes the main scaling objection to trunk-based development — that requiring an up-to-date branch forces constant rebasing when merges are frequent.

Integration cost stays near zero. Divergence never accumulates.

The trunk is always releasable. Not aspirationally — continuously verified.

Fast feedback. Hours between writing and learning whether it works.

A simple mental model. One branch. No question about where a change goes.

Genuine continuous delivery becomes possible. You cannot deploy several times a day from a trunk that is broken half the time.

Linear, readable history. Especially with squash or rebase integration, git log main reads as a sequence of changes rather than a braid.

A broken trunk blocks everyone. With no integration branch to absorb problems, a bad change stops the team. The mitigations are fast CI, fast revert, and a culture where reverting is routine rather than a judgement on the author.

It exposes weak testing immediately. With Git Flow, inadequate tests are partly masked by a stabilisation phase. Remove that and gaps surface in production. Adopting trunk-based development before the tests are ready is the most common way it fails.

Flag sprawl. Discussed above.

It demands decomposition skill. Teams used to two-week branches often cannot immediately break work into daily pieces.

Poor fit for some products. Firmware, medical devices, on-premise software with parallel supported versions — anything where “roll forward quickly” is not available.

Trunk-basedGitHub FlowGit Flow
Long-lived branchesTrunk onlymain onlymain + develop
Typical branch lifetimeHoursHours to daysDays to weeks
Integration frequencyAt least dailyPer pull requestPer feature, then per release
Incomplete workFeature flagsFlags or waitLives on develop
Release mechanismDeploy trunk, or branch at release timeMerge to mainRelease branch, tagged
Requires strong CIEssentialStronglyHelpful
Requires feature flagsEffectively yesOftenRarely
Parallel version supportPoor without release branchesPoorGood
Best forHigh-frequency deliveryMost web teamsVersioned, installed software

Against Git Flow, the difference is fundamental: Git Flow adds structure so divergence can be managed; trunk-based development removes divergence so the structure is unnecessary. They are opposite strategies for the same problem.

Against feature branching generally, the difference is degree. Trunk-based development is feature branching with an aggressive lifetime constraint and the automation to support it.

Switching wholesale rarely works. A workable sequence:

  1. Measure branch lifetime. How old is a branch when it merges?

    Terminal window
    git for-each-ref --sort=committerdate refs/heads/ \
    --format='%(committerdate:short) %(refname:short)'
  2. Fix flaky tests first. Nothing else works until CI is trusted.

  3. Shrink pull requests. Target something reviewable in fifteen minutes.

  4. Make review a priority, not a background task. Branch lifetime is bounded below by review latency.

  5. Introduce feature flags for the first piece of work too large for a day.

  6. Add branch protection requiring checks and an up-to-date branch.

  7. Then talk about the trunk. By this point most of the change has already happened.

Steps 2 to 4 deliver most of the benefit and are worth doing regardless of what you call the workflow.

The practice has an unusually direct measure: how long changes stay unintegrated. Three things are worth watching, and all three can be read out of the repository.

Branch age. How old are open branches?

Terminal window
git for-each-ref --sort=-committerdate refs/heads/ \
--format='%(committerdate:relative)%09%(refname:short)'
3 minutes ago main
2 days ago feature/search-ranking
5 weeks ago spike/new-parser

Anything measured in weeks is not trunk-based development, whatever the team calls it.

Change size. Large changes cannot integrate daily:

Terminal window
git log --oneline --shortstat main -20

Trunk health. What proportion of the day is CI on the trunk red? This is the one that predicts whether the workflow is sustainable — a trunk that is red for hours at a time will drive people back to long-lived branches, because working off a broken base is worse than diverging from a good one.

None of these are targets to optimise directly. They are symptoms: if branch age is creeping up, the cause is usually review latency or change size, and that is where to look.

Trunk-based development treats divergence as the thing to minimise.

Every other practice — small changes, daily integration, feature flags, strong CI, fast revert — exists to keep the gap between what you have and what everyone else has as small as possible.

Judged that way, “can we commit to main?” is the wrong question. The right one is: how long can a change stay unintegrated before it becomes expensive, and what would let us shorten that?

  • Trunk-based development is defined by integration frequency — at least daily — not by banning branches.
  • Most teams practising it use branches that live hours, not by committing directly to the trunk.
  • Integration cost grows faster than linearly with divergence, which is the argument for the practice.
  • Feature flags separate deploying code from releasing behaviour, and must be removed once done.
  • It requires a fast, reliable, comprehensive test suite; anything less pushes failures into production.
  • Merge queues address the scaling problem of testing every change against a moving trunk.
  • Release branches remain compatible, created at release time rather than as a permanent branch.

A short exercise that makes the divergence argument concrete rather than theoretical.

  1. Create a repository with a file containing ten numbered lines. Commit.
  2. Create slow-branch and change lines 2, 5 and 8. Commit but do not merge.
  3. Back on main, make five separate commits, each changing one of lines 1, 3, 5, 7 and 9.
  4. Predict: how many conflicting regions will merging slow-branch produce?
  5. Merge it and count.
  6. Now repeat from scratch, but merge slow-branch into main after the first main commit, then continue. Count the conflicts this time.

The second run has fewer and simpler conflicts for identical changes — the only difference is when integration happened. Scale that up and it is the entire argument.

Branch lifetime is the variable everything here depends on. The next lesson looks at it directly.