Trunk-Based Development

Trunk-based development is a branching strategy in which every developer integrates small, frequent changes into a single shared branch, usually main, instead of working for days or weeks on isolated feature branches. Short-lived branches — often merged within a day, and sometimes committed straight to main behind a feature flag — keep the codebase close to always-releasable, which is exactly what continuous integration and continuous delivery pipelines need. It is the branching model behind most modern SaaS teams practicing CI/CD, and it stands in contrast to heavier models like Git Flow, where long-lived develop, release, and hotfix branches drift apart for weeks at a time.

Overview / How it works

Every branch in Git, including main, is nothing more than a lightweight, movable pointer to a commit. A commit object stores a snapshot as a pointer to a tree object, which points to blob objects for file contents and to other trees for subdirectories, plus metadata such as author, committer, timestamp, parent commit, and message. HEAD normally points to a branch, which points to a commit. Creating a branch just writes a new pointer under refs/heads/ referencing the same commit your current branch is on — nothing is copied. Branching in Git is essentially free, which is exactly why trunk-based development works so well: creating and destroying branches constantly costs nothing.

The core idea is to minimize the distance, measured in commits and time, between any branch and main. In Git Flow a feature branch can drift dozens or hundreds of commits from develop over several weeks, turning the eventual merge into a high-risk event full of conflicts. In trunk-based development a branch lives for hours or a day or two at most, gets a handful of commits, and merges back before main has moved far — so the merge is usually a fast-forward or a small three-way merge with little to no conflict.

Two practices make this safe at scale. Feature flags (feature toggles) let code for an incomplete feature merge into main while wrapped in a runtime conditional, so it never executes in production until switched on — teams can merge continuously without ever shipping half-built work to users. Strong CI is the other half: every push and pull request runs the full test suite, linters, and build, and GitHub branch protection rules block merging until those checks pass and, usually, a reviewer approves. Some trunk-based teams skip pull requests for tiny changes and commit straight to main; most still use short-lived pull requests kept small and fast-moving.

At larger scale, “scaled trunk-based development” adds short-lived release branches cut from trunk right before a release, used only for last-minute stabilization fixes that are cherry-picked back to trunk — never for ongoing feature work. The release branch is deleted once the release ships; it never becomes a second permanent trunk.

Syntax

There is no single git subcommand for trunk-based development — it is a workflow built from ordinary commands used with a particular discipline: branches are created often, kept small, and merged back quickly.

git switch -c "<branch-name>" main
git add "<file>"
git commit -m "<type>: <description>"
git push -u origin "<branch-name>"
  • git switch -c <branch-name> main — creates a new branch from main and switches to it in one step.
  • git add <file> — stages a change in the index, Git’s staging area, before it becomes part of a commit.
  • git commit -m <message> — writes a new commit object; the <type> prefix (feat, fix, chore, docs, refactor) follows the Conventional Commits convention taught throughout this course.
  • git push -u origin <branch-name> — pushes the branch to the remote and, with -u, sets it to track origin/<branch-name> so future git push/git pull need no arguments.

Examples

Example 1: A short-lived branch from creation to cleanup

git switch -c feature/checkout-tax main
git add checkout.js
git commit -m "feat: add sales tax calculation to checkout total"
git push -u origin feature/checkout-tax

Output:

Switched to a new branch 'feature/checkout-tax'
[feature/checkout-tax 3f9a1c2] feat: add sales tax calculation to checkout total
 1 file changed, 12 insertions(+), 1 deletion(-)
branch 'feature/checkout-tax' set up to track 'origin/feature/checkout-tax'.
To github.com:example/shop.git
 * [new branch]      feature/checkout-tax -> feature/checkout-tax

git switch -c creates the branch pointer and moves HEAD to it in one command; the working tree does not change because the new branch starts at the exact commit main was on. The commit writes new blob, tree, and commit objects and advances feature/checkout-tax to point at the new commit; main is untouched. After a reviewer approves the pull request on GitHub and it is squash-merged, main gets one clean new commit. Locally you sync up and delete the finished branch:

git switch main
git pull origin main
git branch -d feature/checkout-tax

Output:

Switched to branch 'main'
Updating 8b12ef4..9c04a71
Fast-forward
 checkout.js | 12 +++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)
Deleted branch feature/checkout-tax (was 3f9a1c2).

Because nobody else committed to main while you worked, git pull applies as a fast-forward: main‘s pointer simply advances to the new commit, no merge commit needed. git branch -d refuses to delete a branch with unmerged work, so it doubles as a safety check that your changes really made it into main.

Example 2: Keeping a short-lived branch in sync with a fast-moving trunk

git fetch origin
git rebase origin/main
git push --force-with-lease

Output:

Successfully rebased and updated refs/heads/feature/checkout-tax.
 + 3f9a1c2...a7c2e10 feature/checkout-tax -> feature/checkout-tax (forced update)

git fetch downloads new commits from origin into origin/main without touching your working tree or index. git rebase origin/main replays your branch’s commits, one at a time, on top of the new tip of origin/main, generating brand-new commit objects with different hashes; the old commits become unreferenced. Because history was rewritten, a normal push is rejected, so git push --force-with-lease is required — it only overwrites the remote branch if it still matches what you last fetched, protecting you from clobbering a teammate’s push you have not seen yet. This is safer than a bare --force, which would overwrite the remote unconditionally.

Example 3: Committing a small, flagged change straight to main

git switch main
git pull origin main
git add pricing-widget.js
git commit -m "feat: add new pricing widget behind FEATURE_NEW_PRICING flag"
git push origin main

Output:

Already up to date.
[main 5e21b90] feat: add new pricing widget behind FEATURE_NEW_PRICING flag
 1 file changed, 40 insertions(+)
   9c04a71..5e21b90  main -> main

This is the most aggressive form of trunk-based development: no branch at all, just a small, flag-guarded, CI-verified commit straight to the shared trunk. It only works safely when the team has strong automated tests and branch protection rules that still run CI on every push, and when the new code path is inert until FEATURE_NEW_PRICING is enabled, so main stays releasable even though the feature is not finished.

How it works step by step

git switch -c writes a new ref under refs/heads/ pointing at the same commit as main; HEAD is updated to point at that ref instead. Editing files changes only the working tree. git add reads each file’s contents, writes a new blob object (or reuses an identical existing one, since Git deduplicates by content hash), and updates the index to record that path against that blob. git commit builds a new tree object from the index, creates a commit object pointing at that tree and at the current branch tip as its parent, then moves the branch ref to the new commit. git push serializes the missing objects and sends them to the remote, then asks the remote to fast-forward its ref for that branch, unless history was rewritten, in which case a normal push is rejected. On GitHub, a squash merge creates one new commit on main combining the whole diff; a merge commit creates a new commit with two parents (main’s old tip and the branch tip); a rebase merge replays each branch commit individually onto main with no merge commit at all. A CI-triggered branch protection check must report success, and if required an approving review, before GitHub allows the merge button to be used at all.

Because trunk-based teams deploy from main constantly, most also wire CI to run on every push and pull request targeting main, so a broken build is caught before it can be merged or deployed:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test
      - run: npm run build

Common Mistakes

Letting a branch live for weeks because it seemed like a small feature defeats trunk-based development entirely: the branch drifts far from main, the eventual merge is full of conflicts, and CI signal on the branch stops reflecting what main actually looks like. Fix: split the work into flag-guarded slices that each merge within a day or two, even if the whole feature takes weeks to fully enable.

Force-pushing over the shared trunk:

git push --force origin main

A bare --force silently overwrites whatever is on the remote, including commits a teammate pushed seconds ago, permanently discarding their work from main‘s history. Fix: never force-push a shared branch like main at all, and enable branch protection to block it outright. On a personal short-lived branch, prefer:

git push --force-with-lease origin feature/checkout-tax

which aborts if the remote has moved since your last fetch, instead of overwriting blindly.

Merging an unfinished feature without a flag breaks the app for every other developer who pulls main and for CI running against it. Fix: wrap incomplete code paths in a feature flag check so main keeps working with the flag off, and only flip it on once the feature is complete and tested.

Skipping CI because a branch looks trivial occasionally lets a broken build reach main, and because trunk-based teams deploy from main constantly, that breakage ships fast too. Fix: require status checks in branch protection for every push to main, with no exceptions, however small the change looks.

Best Practices

  • Keep branches alive for hours to a day or two at most; if a branch would outlive that, break the work into smaller flagged commits instead.
  • Write small, atomic commits with Conventional Commit messages (feat:, fix:, chore:, refactor:) so history stays readable.
  • Gate incomplete or risky code behind feature flags rather than behind long-lived branches.
  • Protect main on GitHub: require pull request reviews, require status checks to pass, and disallow force pushes.
  • Run the full test suite, linter, and build on every push and pull request, not just before release.
  • Rebase or merge from main frequently while a branch is open, so the eventual merge stays trivial.
  • Prefer squash merge for feature branches to keep main‘s history one clean commit per change; use merge commits only when preserving a branch’s internal commit history is genuinely valuable.
  • Cut release branches from trunk only for short-term stabilization, and delete them once the release ships; never let a release branch become a second permanent trunk.

Practice Exercises

  1. Starting from main, create a branch named feature/nav-bar-fix, make a small one-line change, commit it with a Conventional Commit message, push it, and (imagining it was squash-merged on GitHub) update your local main and delete the now-merged branch. Write out the full command sequence.
  2. Trunk has moved three commits ahead of your branch while you were working. Use git fetch and git rebase to bring your branch up to date with origin/main, then push it safely without overwriting anyone else’s work. Which flag do you need on the push, and why?
  3. You need to merge a partially built “dark mode” feature into main without breaking the app for other developers. Describe, in commands and a short flag-check description, how you would gate it behind a feature flag so it can be merged directly into main today, even though the feature is not finished.

Summary

  • Trunk-based development merges small, frequent changes into a single shared branch, usually main, instead of relying on long-lived feature branches.
  • Branches in Git are cheap, movable pointers to commits, which is what makes constant branch creation and deletion practical.
  • Short-lived branches stay close to main, so merges are fast-forwards or small three-way merges with little conflict.
  • Feature flags let unfinished work merge into main safely by keeping new code paths inert until explicitly enabled.
  • Strong CI and branch protection (required status checks, required reviews, no force pushes to main) are what make trunk-based development safe.
  • git push --force-with-lease is the safe way to update a rewritten branch; a bare --force should never touch a shared branch.
  • Scaled trunk-based development cuts short-term release branches from trunk for stabilization, then discards them; it never keeps a second long-lived branch alongside main.