skip to content

In trunk-based development, how do you merge unfinished work into main safely?

level: middleimportance: should knowfreq 45%

answer

  1. Separate merging code from activating behaviour
  2. Isolation moves out of the branch and into the code
  3. One technique is a runtime switch
  4. The other puts a seam in front of the old code
  5. Removal is the last commit, not optional

basics

~20 s

You make the incomplete code inert rather than keeping it on a branch: merge it switched off behind a flag, or introduce it behind an abstraction that still routes to the old implementation. The trunk stays releasable because the new path is never taken.

solid answer

~50 s

The premise of trunk-based development is that "it isn't finished" must not become a reason to keep a branch alive for weeks, so unfinished work is merged in a state where it cannot affect users. Two techniques do that. A **flag** wraps the new path in a runtime condition that is off in production, so the code ships but the behaviour does not. **Branch by abstraction** introduces an interface in front of the code being replaced, merges the new implementation behind it while the old one is still wired up, migrates callers incrementally, and removes the old implementation last — every step is a small, independently mergeable commit. Both convert one large risky merge into a sequence of small safe ones. The discipline that makes it work is a final commit that removes the flag or the dead implementation once the change has landed; without it, the trunk accumulates dead branches in the code instead of in the repository.

code

bash · 6 lines
bash
git log --oneline --graph main -8
* 9f1c2ab Remove legacy payment client and the migration seam
* 7d4e8b0 Route remaining callers to the new client
* 3a91c5e Route checkout callers to the new client
* c02f6d7 Add new client behind PaymentGateway (unused)
* 51ab9e3 Introduce PaymentGateway interface over legacy client

go deeper

for a junior

Recall the key idea: incomplete work is merged in a state where it does nothing — behind a switch or behind an abstraction still pointing at the old code — so main stays releasable.

for a middle

Walk through a branch-by-abstraction migration step by step and explain why each step is independently mergeable, plus why the final removal commit is part of the work.

for a senior

Show the judgment call between a runtime switch and an abstraction seam, and be able to say what a change that cannot be split reveals about the codebase.

for a principal

Own the sustainability angle: this model converts branch divergence into code-level temporary structure, so cleanup has to be enforced as part of done or the codebase accumulates permanent forks nobody chose.

## The problem being solved Trunk-based development asks for branches that live hours or days. Real features take longer. The apparent contradiction dissolves once you separate two things people conflate: **merging code** and **activating behaviour**. A long-lived branch keeps unfinished work out of the trunk by keeping the *code* out. Trunk-based development keeps the *behaviour* out instead, and lets the code merge continuously. That is the whole idea. Everything below is technique for making incomplete code harmless once it is on the trunk. ## Flags The simplest form is a runtime condition around the new path, off by default in production, on in development or for internal users. The new code is compiled, tested, reviewed and shipped; it is just not reached. Because the code is on the trunk, everyone else's changes are merged against it from day one, so it never drifts. From the Git side, this changes what a commit looks like: instead of one merge introducing a whole feature, you get a series of small commits that each add an inert piece, and eventually one small commit that flips the default. The design of the flag system itself — how flags are evaluated, targeted, and rolled out to a percentage of users — is a separate discipline with its own tradeoffs, and not a Git question. ## Branch by abstraction The second technique handles the case a flag handles badly: replacing something large and pervasive, where a condition at the call site would have to be repeated everywhere. The steps are mechanical and each one is mergeable on its own: 1. Introduce an abstraction — an interface or seam — in front of the existing implementation, and route all callers through it. Behaviour is unchanged; this commit is pure refactoring. 2. Add the new implementation behind the same abstraction. It is not selected yet, so it is dead code on the trunk, but it is compiled and tested there. 3. Move callers or traffic to the new implementation incrementally, one area at a time, each as its own small change. 4. Delete the old implementation. 5. Optionally, remove the abstraction if it existed only for the migration. At no point is there a branch holding half a migration, and at no point is the trunk unreleasable. A rollback at any step is a small revert rather than an unpicking of a huge merge. ## Why this is a Git-workflow topic at all Because it is the answer to the obvious objection to trunk-based development. An interviewer who asks "how can you merge daily when a feature takes three weeks?" is testing whether you understand that the model relocates the isolation from the branch to the code. Candidates who have not internalised that usually claim trunk-based development only suits trivial changes. It also has direct consequences for how you commit. Work must be split into pieces that are individually safe and individually reviewable, which shapes the commit sequence and the size of each merge. A change that cannot be split that way is a signal about the codebase — often that a seam is missing — and that signal is exactly what branch by abstraction responds to. ## The cost, stated honestly Both techniques put temporary structure in the codebase: a condition that will never be false again, an abstraction with one implementation, a dead code path nobody removed. That debt is real, and it compounds because each leftover makes the code harder to read for everyone. So the last step is not optional. The change is finished when the flag and the code it guarded are gone, or when the old implementation and the migration-only abstraction are deleted — and those removals are themselves small commits on the trunk. Teams that treat the cleanup as part of the work keep the model sustainable; teams that treat it as follow-up eventually have a codebase full of forks that no branch ever contained. ## What a strong answer includes Name both techniques, explain that they move isolation from the branch to the code, walk one migration through its steps, and state the cleanup commit as part of the definition of done. Mentioning that the merge stays small at every step — and that a rollback is therefore a small revert — shows you understand why the model reduces risk rather than just relocating it.

  • When is branch by abstraction the better choice than a flag?
    When the change is a replacement rather than an addition, and a condition would have to be repeated at many call sites — swapping a storage layer, a client, a rendering path. The abstraction gives one place where selection happens, so callers migrate incrementally without each needing its own switch, and the old implementation is deleted in a single final commit.
  • What is the Git-visible sign that a team claims trunk-based development but is not doing it?
    Branches whose first commit is weeks old. If `git log` shows merges whose merge base sits far back, or branches that get updated from main repeatedly to stay alive, isolation is still living in the branch. Genuine trunk-based history is a dominant line with short side branches spanning a handful of commits.
  • Why does the cleanup commit matter so much?
    Because the technique trades repository-level divergence for code-level divergence, and only the cleanup ends the trade. A flag never removed is a permanent extra path through the system, and a migration-only abstraction left in place misleads every future reader about why it exists. Both make the code harder to change than the branch would have been.

saying these in an interview costs you the question

  • Says trunk-based development only works for small features
  • Merges unfinished code with no way to keep it inert
  • Treats flag or abstraction removal as optional follow-up
  • Confuses a switched-off code path with untested code
  • Keeps a branch alive because the feature is not done

context