Skip to content

Short-Lived Branches: Why Integration Frequency Matters

Lesson 6 of 8Beginner → Intermediate8 min readModern Git Workflows · BranchingVerified: Git 2.43.0 on Ubuntu 24.04

A short-lived branch is one that is created, developed and integrated before it has time to diverge meaningfully from the branch it will merge into. In practice that usually means hours, sometimes a day or two.

This lesson is about one variable, because it turns out to be the variable that predicts most of a team’s Git pain: how long work stays unintegrated.

The moment you branch, two histories begin evolving independently. Every commit that lands on main and is not on your branch is a difference somebody will eventually reconcile.

Divergence accumulates on both sides

A trunk lane labelled main with six commits A through F. A branch lane labelled feature leaves main after commit A and has three commits of its own, X, Y and Z, never merging. Both lanes have moved on independently since the shared commit A.

ABCDEFXYZmainfeatureFive commits on main and three on feature must be reconciled against merge base A.

The cost of reconciling is not proportional to the number of changes. It is driven by how those changes interact:

  • Changes to unrelated files cost nothing — Git combines them without asking.
  • Changes to different parts of the same file usually cost nothing.
  • Changes to the same lines produce a conflict a human must resolve.
  • Changes that are individually fine but semantically incompatible produce something worse: a clean merge that does not work.

As each side grows, the number of possible interactions grows faster than the number of changes. That is why doubling a branch’s lifetime tends to more than double the integration effort.

Textual conflicts are the visible cost, and they are not the expensive one. Git tells you about them.

The expensive case is the semantic conflict: two changes that touch different lines, merge cleanly, and are wrong together.

You rename calculate_total() to calculate_order_total() and update all four call sites. Meanwhile a colleague adds a fifth call site on their branch, using the old name. Both branches are correct in isolation. Git merges them without complaint, because you edited different regions. The result does not compile.

That example is benign because a compiler catches it. Replace it with a change to a validation rule and a new code path that assumes the old rule, and nothing catches it until production.

Short-lived branches reduce this class of problem for a simple reason: the fewer changes in flight simultaneously, the fewer opportunities for two of them to be quietly incompatible.

Rather than a number, use these tests. A branch is short-lived enough if:

  • It can be reviewed in one sitting without the reviewer losing context.
  • You can hold its full contents in your head while working on it.
  • Rebasing or syncing it is uneventful rather than a task you schedule.
  • Nobody says “let me finish this first” when asked to integrate.

A branch fails those tests long before it becomes a legendary merge. The signals are noticeable early: you start merging main in “to keep it current”, conflicts recur in the same files, the diff no longer fits on a screen.

Branch lifetime is not something you can decide directly. It is an outcome of four other things.

The dominant factor. A branch cannot be short if the work on it is large.

Decomposing means finding intermediate states that are safe to integrate even though the feature is incomplete:

  • Add the new database column in one branch; use it in another.
  • Add a new function nothing calls; wire it up next.
  • Introduce an interface, add a second implementation behind it, switch, remove the old one — four branches, each independently safe.

That last pattern is branch by abstraction, and it is how large refactors ship without long-lived branches.

Branch lifetime has a hard floor at review latency. If review takes a day, branches live at least a day.

What helps: smaller pull requests (a fifty-line change gets reviewed far sooner than a thousand-line one), treating review as a scheduled interrupt rather than background work, and tiering by risk so low-risk changes need less ceremony.

When work genuinely cannot be finished quickly, integrate it disabled. The code reaches main daily; the behaviour is released later. Trunk-Based Development covers the techniques and their costs.

Slow or flaky CI extends branch lifetime directly — people batch changes to avoid waiting, and batching is exactly what makes branches long.

These are conventions, not Git features. Their value is in being explicit rather than assumed:

PolicyEffect
Target pull requests reviewable in 15 minutesBounds change size
Review within one working dayBounds the lifetime floor
Delete branches automatically on mergeKeeps the branch list meaningful
Flag branches inactive for N days for reviewSurfaces abandoned work
Require branches up to date before mergingPrevents merging against a stale base
Prefer several small pull requests to one large oneMakes decomposition the default

Before changing anything, look at the current state:

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

For remote branches, substitute refs/remotes/origin/.

Then check how far each has diverged:

Terminal window
git rev-list --left-right --count main...feature/search-ranking
14 3

Fourteen commits on main that the branch has not seen; three on the branch. The first number is the one that predicts pain.

To find branches nobody is working on any more:

Terminal window
git branch --merged main

Everything listed is already integrated and safe to delete.

Rescuing a branch that is already too long

Section titled “Rescuing a branch that is already too long”

The advice above prevents the problem. It does not help with the branch you already have. Three approaches, in order of preference.

Split it into landable pieces. The best outcome, and more often possible than it looks. Identify the parts that are independently safe — a new module nothing calls, a test-only change, a refactor with no behaviour change — and land those first as separate branches. The remainder shrinks, sometimes dramatically.

Terminal window
git switch -c extract/parser-helpers main
git checkout feature/big-rewrite -- src/parser/helpers.py
git commit -m "Extract parser helpers"

git checkout <branch> -- <path> copies specific files from another branch into your working tree and index without switching to it. That gives you a small branch carrying one coherent slice.

Integrate it behind a flag. If the work is coherent but incomplete, merge it disabled rather than letting it age further. The divergence stops immediately; finishing continues on the trunk.

Bring it up to date, then finish it quickly. If neither of the above applies, at minimum stop the divergence growing:

Terminal window
git switch feature/big-rewrite
git fetch origin
git merge origin/main # or rebase, if the branch is private

Then treat finishing it as the priority rather than one task among several. A branch that is already expensive gets more expensive every day it stays open.

The “spike” branch that becomes production code. An exploratory branch is fine. One that quietly becomes the real implementation after three weeks is a long-lived feature branch with a misleading name.

The personal long-running branch. dev/alice that never merges is a private fork. All the divergence cost, none of the review.

Repeatedly merging main in to “stay current”. Reasonable occasionally. Done daily out of habit it clutters the branch with merge commits, makes review harder, and — importantly — does not reduce the divergence that matters, because your changes still have not reached anyone else.

Batching several finished changes into one pull request because opening three feels like more work. It is less work for you and considerably more for the reviewer.

Blocking integration on unrelated work. “I will merge once the tests I am also writing are done” converts a one-hour branch into a three-day one.

Treating branch age as the target. Age is a symptom. A team that closes branches quickly by splitting work arbitrarily, so that no individual branch does anything coherent, has optimised the metric and lost the point.

Not every long branch is a mistake:

  • A genuine spike. Exploring an approach you may discard. It should be deleted or decomposed, not merged.
  • A change that is not safely decomposable. Some migrations only make sense atomically. Take the cost deliberately, sync often, and integrate as soon as it is coherent.
  • An external contribution. You do not control a contributor’s pace.
  • A release branch. Deliberately long-lived, with a defined purpose and only fixes applied. See Release Branches.

The distinction is intent. A branch that is long because someone decided it must be is different from one that is long because nobody decided anything.

A branch is a loan against future integration effort.

Opening one borrows time now — you can work without coordinating. Every day it stays open accrues interest, and the interest compounds. Merging repays the loan.

Short-lived branches keep the loan small enough that repayment is trivial. Long-lived ones eventually cost more to repay than the work was worth.

  • Branch lifetime predicts integration cost better than change count does.
  • Integration cost grows with how changes interact, so it rises faster than linearly with divergence.
  • Semantic conflicts — clean merges that are wrong — are the expensive failure, and only tests catch them.
  • Branch lifetime is an outcome of change size, review latency, feature flags and CI speed.
  • Merging main into a branch repeatedly does not address the divergence that matters.
  • Deliberately long-lived branches exist; the problem is branches that are long by accident.
  1. In any repository you work in, run the branch-age command above. How old is the oldest branch?
  2. Pick the oldest and run git rev-list --left-right --count main...<branch>.
  3. Run git diff --stat main...<branch> — note the three dots — to see its true size.
  4. Estimate how long integrating it would take, then ask whoever owns it.
  5. Run git branch --merged main and delete anything listed that is no longer needed.

Step 4 is usually instructive. Estimates for old branches are consistently optimistic, which is itself the argument for not creating them.

Release branches are the main case where a deliberately longer-lived branch earns its cost.