Software Engineering · Version Control Practice

Branch Lifetime and the Cost of Divergence

How branch age affects conflict size, merge base distance, and review quality, and what integration frequency changes mechanically rather than culturally.

Branch lifetime is usually discussed as a process preference. The technical content is narrower and more concrete: a branch that lives longer has a merge base further back, and the distance between the base and the tip determines how much unrelated change sits between the two sides at integration time. Everything that follows — conflict volume, review difficulty, the risk of a merge that compiles but is wrong — is a function of that distance rather than of elapsed time as such.

The base drifts backwards

When a branch forks from the main line, its merge base is the fork point. As the main line advances and the branch does not, the base stays put while the tip moves further from it. At integration, three-way merge compares the branch against a base that no longer resembles the current main line, and it compares the main line against the same stale base.

The practical effect is that the automatic resolution sees more divergence than necessary. Suppose a function was renamed on the main line while the branch added a call to it under the old name. If the base predates the rename, both sides appear to have touched adjacent lines and the merge may conflict or, worse, merge cleanly into a call to a function that no longer exists. If the branch had been rebased or merged forward after the rename, the branch’s call would have been written against the new name and no conflict would exist at all.

So the conflict count is not a property of the two changes. It is a property of the two changes measured from a common ancestor, and the ancestor is a choice maintained by integration frequency. Merging the main line into a long-lived branch periodically moves the base forward and pays the conflict cost in small instalments rather than one large one. The total number of conflicting hunks over the branch’s life is roughly the same; the distribution is not, and a series of small resolutions is easier to review and easier to attribute than one large one.

There is a limit to the analogy with interest. Conflicts do not compound the way debt does, because each resolution is a real decision recorded in the graph. What compounds is the size of the diff a reviewer must hold in mind, and the probability that two independently made changes interact in a way neither author considered.

Trunk-based and its variants

The extreme of high integration frequency is committing to the main line directly, with branches measured in hours. The model has a hard requirement that is easy to overlook: the main line must always be in a state that can be released, or the whole arrangement collapses into a shared workspace where broken states are normal. That requirement is what makes feature flags, dark launches, and branch-by-abstraction necessary rather than optional.

Branch by abstraction is the structural version: introduce an interface or an indirection at the call sites while both implementations exist, ship the indirection, then switch and remove the old path in a later commit. Each commit is small and the main line stays releasable throughout. The cost is temporary complexity — a seam that exists only to allow two implementations to coexist.

Feature flags move the same problem to runtime. A flag lets incomplete code sit on the main line while being unreachable, at the cost of a configuration surface that must be cleaned up when the work lands. Flags that outlive their feature become permanent branches in disguise, and their interaction count grows quadratically with the number of flags that can be combined.

The opposite extreme is a long-lived release branch with periodic merges from a development line. Here the merge base is deliberately kept far back so that the release line can take selected fixes without absorbing everything. The cost is that the same fix often has to exist on both lines, and the second application is a cherry-pick, which produces a different commit id for the same change. That duplication is a feature of the model, not a defect, but it does mean the two lines must be compared by content rather than by ancestry when asking whether a fix is present.

What review sees

A short-lived branch produces a diff that can be read against the current tip. A long-lived branch produces a diff against a base that the reviewer must reconstruct mentally, and a merge commit whose resolution has no natural review location. Reviewing a merge is reviewing the union of two changes plus the resolution, and the resolution is the only part that is new.

This is where the interaction with history model becomes concrete. On a rebase-based flow, the branch is replayed onto the current tip before review, so the diff shown is exactly what will land. On a merge-based flow, the branch is merged forward first, and the review covers the merge resolution plus the branch’s own commits. The first is simpler to review; the second records the fact that the branch had to be reconciled.

Neither is free. Rebase-before-review means the reviewed diff is a reconstruction, and any conflict resolution performed during the rebase is invisible as a separate change. Merge-forward means the resolution is visible but the diff is no longer a clean patch against the tip.

Estimating divergence

Three quantities are computable and worth tracking rather than guessing.

The merge base distance is the number of commits on the main line between the fork point and the current tip. git rev-list --count main...topic gives the symmetric difference count, and git rev-list --count $(git merge-base main topic)..main gives the drift on the main line alone. A branch whose base is hundreds of commits behind is one where automatic merge has stopped being a reliable signal.

The touched-path overlap is the intersection of the two sides’ changed file sets relative to the base. Branches that touch disjoint paths merge trivially regardless of age; branches that touch the same files conflict in proportion to how much of those files changed. Computing the intersection before integration is a cheap way to predict conflict cost, and it is the reason “keep branches small” is more precisely stated as “keep the set of concurrently edited files small”.

The third quantity is churn on the touched paths. A file rewritten by an automated formatting pass between the fork and the integration will conflict with any branch edit in it, because every line changed. This class of conflict is entirely mechanical and is best handled by making the reformat a separate commit that lands before the branch, or by resolving it with a strategy that ignores whitespace.

When long-lived branches are correct

The argument for short branches is about merge cost and review, not about correctness. Long-lived branches are appropriate when the work genuinely cannot be integrated incrementally: a platform migration that must switch atomically, a release line that must remain stable while development continues, or a change that requires a coordinated deploy across components that cannot be versioned independently.

In those cases the mitigation is to reduce the surface rather than the duration. Merge the main line forward frequently so the base stays close, even if the branch does not land. Keep the branch’s own commits small and ordered so a replay is possible. Separate mechanical changes from semantic ones so conflicts can be resolved by rule.

A release line deserves separate treatment because its merge base is intentionally distant. Fixes are cherry-picked onto it, so the same change exists as two commits with different ids. The question “is this fix in the release” cannot be answered by ancestry; it has to be answered by content comparison, which is what git cherry and patch-id are for, covered in cherry-pick, patch identity, and duplicate commits.

Integration as a merge policy

The choice of when to integrate is expressed in the repository as a policy about who merges what, in which direction. A common arrangement: the main line never takes a merge from a feature branch that has not first been rebased onto it, so the main line’s first-parent history is linear and each integration is a fast-forward or a squashed commit. Another: every feature lands as a merge commit, so the branch’s internal commits remain reachable through the second parent and the main line’s first-parent line records integrations.

Both are consistent. What is not consistent is mixing them per branch without a rule, because the resulting history has no single reading. A reviewer asking “what changed on the main line last week” gets one answer with --first-parent on a merge-based repository and a different answer on a rebase-based one, and the difference is not a matter of taste — it is a different set of commits.

The mechanical fact underneath all of it is that integration cost scales with the distance between the merge base and the two tips, and that distance is under direct control through how often the base is advanced. Branch lifetime is one way to control it; merging forward is another; keeping the edited file set disjoint is a third. All three reduce the same quantity, and the trade is between the discipline they require and the reviewability of the history they produce. The topology options are set out in fast-forward, merge commits, and first-parent history, and the effect on conflict handling appears in conflict markers, merge drivers, and recorded resolutions.

References

  1. Continuous Integration: Improving Software Quality and Reducing Risk — Duvall, Matyas & Glover, Addison-Wesley 2007
  2. Accelerate: The Science of Lean Software and DevOps — Forsgren, Humble & Kim, IT Revolution Press 2018
  3. Git documentation — git-merge-base — Git project
  4. Version Control by Example — Eric Sink