The previous lesson left a question open. GitHub Flow asks for short branches, but it does not say how short, and it offers no answer for work that does not fit into three days.

Trunk Based Development (often abbreviated to TBD) answers both with a rule that sounds almost aggressive:

Every person on the team integrates their work into the trunk at least once a day.

Not "when the feature is ready". Not "when it passes review". Every day. Even if the feature is not finished. Even if it is not yet good for anything.

Almost everybody's first reaction is the same: that is impossible, how am I going to integrate half-finished code into the branch that gets deployed to production? That objection is correct, and answering it is the central content of this lesson. There is a mechanism that makes it possible — separating deployment from release — and without understanding it, TBD looks reckless. With it, it is probably the practice with the greatest impact on a team's delivery speed.

In task-manager, the team has been running GitHub Flow on the cloud version for a while now. It works, but they have spotted a pattern: two or three times a month, a branch that has lived for ten days arrives with conflicts in app.js that take half a day to resolve and that sometimes introduce bugs. Ana proposes going a step further.

Contents

  1. The idea and where it comes from
  2. Why the real goal is reducing the time between integrations
  3. The model's two variants
  4. Separating deployment from release: feature flags
  5. The cost of flags: flag debt
  6. Branch by abstraction: large changes without long branches
  7. Release branches only when they are needed
  8. What TBD demands
  9. Final comparison of the three flows
  10. A decision tree: which one to choose

  1. The idea and where it comes from

Trunk Based Development is not a new model: it is the oldest of the three. Before Git made branches cheap, branching was expensive and painful — in CVS or Subversion, a two-week branch was an adventure — and that is why everybody worked against the mainline. TBD takes that old practice and reclaims it with modern arguments.

Its current formulation comes from two places. From the continuous integration movement — Kent Beck and extreme programming in the nineties, which already said "integrate at least once a day" — and from the book Continuous Delivery by Jez Humble and David Farley (2010). It is also the model used internally by Google, Facebook and other companies with enormous repositories.

The model's elements:

  • One trunk (main, trunk, master): the only long-lived branch.
  • Everybody integrates there at least once a day.
  • Branches, if they exist, live for hours, not days.
  • Unfinished work travels to the trunk hidden behind a flag.
  • The trunk is always healthy, guaranteed by automated tests.

The difference from GitHub Flow is smaller than it looks and more important than it looks. Both have a single long-lived branch. The difference is the mandatory frequency of integration and the techniques needed to sustain it. GitHub Flow says "short branches"; TBD says "less than a day, no exceptions, and here is how".

  1. Why the real goal is reducing the time between integrations

This section is the foundation. If this is not understood, TBD looks like an arbitrary rule.

The conflict curve

When two people work on the same code in separate branches, their versions diverge. And the cost of reconciling that divergence does not grow linearly with time: it grows much faster.

Time apart Divergent commits Typical cost of integrating
Hours 1–3 Almost always automatic
1 day 3–10 An occasional, trivial conflict
1 week 20–50 Several conflicts, half an hour
2 weeks 50–150 Serious conflicts, half a day, risk of error
1 month or more 150+ Days, and the work often gets redone

The reason for the acceleration is combinatorial. With two divergent commits there are few ways of clashing. With two hundred, the probability that somebody has touched the same file is almost one, and on top of that the changes have been built on different assumptions: it is not just that the lines clash, it is that the function you were going to modify no longer exists, or the module has been split into three.

And there is a type of conflict Git does not detect: the semantic conflict. Ana renames a function in app.js and Carla, in her branch, adds a call to that function under the old name. On merging, there is no textual conflict — they touch different lines — but the code is broken. The longer they are apart, the more of these appear, and only the tests catch them.

flowchart LR
    A["Infrequent<br/>integration"] --> B["Large<br/>divergence"]
    B --> C["Costly, risky<br/>conflicts"]
    C --> D["Fear of integrating"]
    D --> A
    E["Daily<br/>integration"] --> F["Minimal<br/>divergence"]
    F --> G["Trivial or non-existent<br/>conflicts"]
    G --> H["Integrating is routine"]
    H --> E

Both loops are stable. The top one is what the task-manager team suffers twice a month. The bottom one is where Ana wants to get to.

The consequence: it is a problem of frequency, not of tooling

Here is the conceptual twist that gives the practice its name. Continuous integration is not a CI server: it is genuinely integrating, and often. A team can have the best checking server in the world and not be doing continuous integration, because each person works for three weeks in their own branch and the server only tests isolated branches. We shall come back to this idea in lesson 07-06, because it is the very definition of CI.

TBD is simply the branching policy that makes genuine continuous integration possible. Everything else in the model consists of techniques for making that policy viable.

  1. The model's two variants

TBD comes in two forms, and the choice depends above all on team size and on whether review is mandatory.

Variant A: committing straight to the trunk

Each person commits directly to main, several times a day.

git switch main
git pull --rebase
# edit app.js
git commit -am "Add the skeleton of the statistics panel"
git pull --rebase
git push

The pull --rebase before pushing keeps the history linear and avoids trivial merge commits (lesson 05-01).

Non-negotiable requirements: a fast test suite that runs before committing, a small team (2–5 people), a lot of mutual trust and, normally, pair programming as a substitute for asynchronous review.

When it makes sense: very small, experienced teams, or projects where the cost of a mistake is low. In practice it is a minority approach, because it collides head-on with mandatory review and with the protected branches of lesson 07-04.

Variant B: very short-lived branches

The majority variant. Branches that live for hours, with a pull request and a quick review.

# 09:15 — work starts
git switch main && git pull
git switch -c ana/stats-panel

# 09:15–11:30 — work
git commit -am "Add the skeleton of the statistics panel"
git push -u origin ana/stats-panel

# 11:30 — PR, review in under an hour, merge, delete the branch
# 12:45 — back on the trunk. The next branch begins.

It looks a lot like GitHub Flow, and in fact the border between the two is blurry. The real differences:

GitHub Flow TBD variant B
Branch lifetime Days (sometimes more) Hours, less than a day
Branch scope A complete feature One step towards the feature
Is unfinished code integrated? No Yes, behind a flag
Review Careful, can take a while Quick, minimising the wait

The second row is the key one. In GitHub Flow, the unit of work is "the feature". In TBD, the unit is "what I can integrate today without breaking anything". A large feature becomes eight or ten successive integrations, each one safe in its own right.

A note on names: in TBD it is common to prefix the branch with the name of whoever creates it (ana/stats-panel, bruno/label-cache). It makes it clear at a glance that it is a personal, ephemeral branch, not a shared project branch.

  1. Separating deployment from release: feature flags

And now, the objection from the start: how do you integrate unfinished code daily into a branch that gets deployed to production?

The answer is one of the most important ideas in modern software engineering:

Deploying (the code being in production) and releasing (users using it) are two different things and can happen at different moments.

The mechanism is feature flags (also called feature toggles): conditions that decide at run time whether a feature is active.

The example in task-manager

Ana is building the statistics panel. It will take her two weeks. Instead of a two-week branch, on the first day she integrates this into app.js:

// config/flags.js
// task-manager feature flags.
// Each entry documents who created it and when it must be removed.
export const FLAGS = {
  // GT-352 — Ana Ferrer — created 2026-08-03 — remove after release
  statsPanel: false,

  // GT-338 — Bruno Salas — created 2026-07-20 — remove before 2026-08-15
  csvExport: true,
};
// app.js
import { FLAGS } from './config/flags.js';

function renderSidebar() {
  const sidebar = document.querySelector('#sidebar');
  sidebar.innerHTML = '';
  sidebar.append(buildFilterList());

  // The feature travels to production, but switched off.
  if (FLAGS.statsPanel) {
    sidebar.append(buildStatsPanel());
  }
}

What she has achieved with this:

  • The code is in production from day one. It is deployed, it is built, it is tested alongside everything else.
  • No user sees it, because the flag is off.
  • There is no two-week branch, so there is no divergence and no large conflicts.
  • Each day Ana integrates one more step of buildStatsPanel(), always behind the same flag.
  • The day it is ready, releasing it is changing false to true: a one-line change, with no deployment of new code.
  • And if something goes wrong, switching it off is changing true to false: the fastest and least risky rollback there is. No hurried git revert at eleven at night.

That last point is underrated. Rolling back a deployment with Git means building, testing and deploying again; switching off a flag is instant and does not touch the code.

Degrees of sophistication

Flags can carry a lot more logic than a boolean in a file:

// flags.js — version with selective activation
const CONFIG = {
  statsPanel: {
    enabled: true,
    // Only for the internal team while it is being polished
    users: ['ana.ferrer@example.com', 'bruno.salas@example.com'],
    // And for 10% of everyone else, to measure the impact
    percentage: 10,
  },
};

export function isEnabled(name, user) {
  const f = CONFIG[name];
  if (!f || !f.enabled) return false;
  if (f.users?.includes(user.email)) return true;
  if (f.percentage) return stableHash(user.id) % 100 < f.percentage;
  return true;
}
// Use in app.js
if (isEnabled('statsPanel', currentUser)) {
  sidebar.append(buildStatsPanel());
}

With this, possibilities appear that have nothing to do with Git but that explain why flags have become so widespread: progressive rollout (enable for 1%, then 10%, then everyone), A/B testing, per-client activation and emergency shutdown.

The four families of flag

Not all flags are the same, and confusing them is the source of most of the problems:

Type What for Expected lifetime Is it removed?
Release Hiding work in progress until it is ready Days or weeks Yes, always
Experiment A/B testing, measuring a variant Weeks Yes, once decided
Operational Switching off something heavy under load (kill switch) Permanent No
Permission Features by subscription plan Permanent No

The first two are temporary by definition, and treating them as though they were permanent is exactly the problem of the next section. The last two are legitimate product configuration and are not debt.

An important Git detail: the flags file is version-controlled code. Changing a flag is a commit, with its message, its review and its trace in the history. When somebody asks "when was the statistics panel switched on?", git log -- config/flags.js answers it. Flag management platforms move that configuration outside the repository, which gains immediacy and loses traceability; it is a conscious trade-off.

  1. The cost of flags: flag debt

Flags are not free, and it is worth saying so with the same clarity with which their advantages are sold.

Every flag doubles the possible paths through the code. With one flag there are two behaviours to test. With ten independent flags there are, in theory, 1024 combinations. Nobody tests 1024 combinations. In practice two or three get tested, and the rest is unexplored territory where the strange bugs that only happen to one client live.

The symptoms of a team with flag debt:

  • Flags from two years ago that nobody dares touch because nobody knows what they do.
  • Dead code behind permanently disabled flags, which still gets built and maintained.
  • Nested flag conditionals: if (A && !B) { ... } else if (B && C) { ... }.
  • Bugs that only reproduce with one specific combination of flags, impossible to debug.
  • Nobody knows what is actually enabled in production.

The discipline that prevents it

Rule 1: every temporary flag is born with an expiry date. Written in the code itself, as in the example in the previous section. If it is still there when the date arrives, that is an automatic ticket.

Rule 2: removing the flag is part of the task, not an extra. The feature is not "finished" when it is released: it is finished when the flag has disappeared from the code. Many teams create the removal ticket at the very moment of creating the flag.

Rule 3: the removal is a change of its own, and a small one.

// Before
if (FLAGS.statsPanel) {
  sidebar.append(buildStatsPanel());
}

// After: the condition and the entry in flags.js are deleted
sidebar.append(buildStatsPanel());
git switch -c cleanup/remove-stats-panel-flag
git commit -am "Remove the statsPanel flag

The feature has been on at 100% for three weeks with no incidents.
Closes GT-352."

Rule 4: audit periodically. A simple command, runnable from CI, that lists the flags and their age:

# Declared flags and when each line was last touched
git blame --date=short -- config/flags.js | grep -E '^\S+.*: (true|false)'

Rule 5: set a ceiling. Some teams limit the number of simultaneous temporary flags (five, for example). To create the sixth, one has to be removed. It is artificial, and it works.

The trade-off, said without frills: flags swap branching complexity (which Git handles badly in long branches) for code complexity (which is handled with discipline and deletion). It is a good deal only if the deletion genuinely happens. A team that adds flags and never removes them ends up somewhere worse than if it had used long branches.

  1. Branch by abstraction: large changes without long branches

Flags are good at "adding something new, hidden". They are not good at replacing something existing with something else: swapping localStorage for a server API, migrating ui-components to an incompatible version, rewriting the rendering engine.

That is what branch by abstraction is for: a technique that achieves the same effect without branches and without flags scattered all over the code. It is five steps, and each one is integrated into the trunk separately.

Step 1: introduce an abstraction between the calling code and the current implementation.

// storage/index.js  — a new layer, with no change in behaviour
import * as local from './local-storage.js';

export const saveTasks = local.saveTasks;
export const loadTasks = local.loadTasks;

All the code moves to using storage/index.js. Nothing changes functionally, so it is a safe integration and reviewable in ten minutes.

Step 2: build the new implementation behind the same interface. It gets integrated daily, without anybody using it yet.

// storage/api-storage.js
export async function saveTasks(tasks) { /* call to the server */ }
export async function loadTasks() { /* call to the server */ }

Step 3: choose the implementation with a flag. A single one, in a single place.

// storage/index.js
import { FLAGS } from '../config/flags.js';
import * as local from './local-storage.js';
import * as api   from './api-storage.js';

const impl = FLAGS.serverStorage ? api : local;

export const saveTasks = impl.saveTasks;
export const loadTasks = impl.loadTasks;

Step 4: migrate progressively. Enable it for the team, then for 5%, then for everybody, keeping an eye on things.

Step 5: remove the old one. Delete local-storage.js, delete the flag and — if it no longer adds anything — flatten the abstraction.

The advantage over a two-month branch is decisive: at no point does a large divergence exist. Each step is a small change, reviewable, integrated and deployed. And if halfway through you have to stop and attend to another priority, the work done is already in the trunk and is neither lost nor left to rot.

  1. Release branches only when they are needed

TBD does not forbid release branches: it says they should not exist by default. They are created when there is a specific reason, and always from the trunk.

# Only if a specific version has to be frozen
git switch -c release/3.2 main
git tag -a v3.2.0 -m "Version 3.2.0"
git push -u origin release/3.2 --follow-tags

And there is a directional rule that defines the model:

Fixes are made on the trunk first and then taken to the release branch, never the other way round.

# 1. Fix it on the trunk (where EVERYTHING gets fixed, always)
git switch main && git pull
git switch -c fix/due-date
git commit -am "Fix the time zone in the due date"
# PR, review, merge into main

# 2. Take the fix to the affected version
git switch release/3.2
git cherry-pick <hash-of-the-fix-in-main>
git tag -a v3.2.1 -m "Version 3.2.1"
git push origin release/3.2 --follow-tags

This technique is called backporting, and it uses exactly the command from lesson 05-03. Compare it with Git Flow's hotfix/*, which fixed things first in main (production) and then propagated to develop:

Git Flow TBD
Where it gets fixed first On the production branch (hotfix/ from main) Always on the trunk
How it reaches the other place Merge back into develop cherry-pick to the release branch
Risk of forgetting The fix does not reach development: the bug comes back The fix does not reach the old version: the client still has the bug

TBD's risk is more benign. If you forget the cherry-pick, the old client still has the bug — bad, but visible and something they can chase up. If in Git Flow you forget the second merge, the bug reappears in the next version, which is worse and far more confusing.

Release branches in TBD should also live briefly: they are created, they receive their patches, and when the version stops being maintained they are abandoned. They are not permanent branches.

  1. What TBD demands

Like GitHub Flow, TBD buys structural simplicity by paying in discipline and automation. Here the bill is higher still.

Demand 1: a fast, reliable test suite

Fast is as important as reliable. If CI takes forty minutes, nobody integrates three times a day: people pile up work to amortise the wait, and you are no longer doing TBD. The practical benchmark is under ten minutes for the suite that blocks integration. How to achieve it — caching, parallelism, running only what is affected — is the subject of lesson 07-06.

Reliable means zero flaky tests. With daily integrations from the whole team, a test that fails 5% of the time becomes several false reds a day and the team learns to ignore them.

Demand 2: a culture of small commits

You have to know how to decompose the work. It is a specific skill and it can be trained: faced with "build the statistics panel", being able to see eight steps that can be integrated separately without breaking anything. Anybody who can only think in units of "complete feature" cannot do TBD.

Demand 3: CI on every push

Every push to any branch, and every integration into the trunk, triggers the checks. No exceptions.

Demand 4: a healthy trunk is the absolute priority

Just as in GitHub Flow, but sharper: here the whole team works against the trunk several times a day. A broken trunk does not block whoever is deploying, it blocks everybody at once. The usual policy is to revert immediately and fix on a branch.

Demand 5: fast review

If a PR representing three hours of work waits two days to be reviewed, the model breaks. Teams that do TBD adopt explicit commitments (review within an hour), review in pairs, or pair programming as a substitute.

Demand 6: maturity to use flags well

Creating flags is easy. Removing them demands sustained discipline. A team without it will accumulate flag debt until the code is unmanageable.

  1. Final comparison of the three flows

We now have all three models in full. This is the table that summarises the module.

Criterion Git Flow GitHub Flow Trunk Based
Long-lived branches 2 (main, develop) + support 1 (main) 1 (the trunk)
Classes of supporting branch 3 (feature, release, hotfix) 1 (working branch) 1 (ephemeral branch) or none
Integration frequency Low: when the feature is finished (weeks) Medium: days High: at least once a day
Typical branch lifetime 1–4 weeks 2–5 days Hours
Process complexity High: 5 classes, double integration Low Very low in branches, high in technique
Type of delivery Planned, by version Continuous, after each PR Continuous, several a day
Team size Medium and large, with roles Small and medium Small to very large (with investment)
Maintaining old versions Excellent: it is its whole reason for existing Poor: requires adding support branches Acceptable: release branches + backport
Cost of conflicts High: long divergence Medium Very low: almost no divergence
Demand for automated tests Medium (there is manual QA on the release branch) High Very high
Time from idea to production Weeks or months Days Hours
Learning curve High (a lot of protocol) Low Medium (the protocol is simple; the techniques are not)
Risk per deployment High: many changes together Low: one change per deployment Very low: tiny changes
Large unfinished work In its own branch, isolated An unsolved problem Flags and branch by abstraction
Emergency rollback A new hotfix + a patch version git revert + a deployment Switch off a flag: instant

Two readings worth taking from the table:

First: the complexity does not disappear, it moves. Git Flow puts it in the branch structure (five classes, rules of origin and destination, double integrations). TBD puts it in the code and the automation (flags, abstractions, fast tests, discipline). GitHub Flow sits in between. No model is "simpler" in absolute terms: they choose where to pay.

Second: there is a direct relationship between technical maturity and viable model. The less reliable your automation, the more branch structure you need as a safety net. A team with no automated tests that adopts TBD is not being modern: it is deploying unchecked code. The natural progression is Git Flow → GitHub Flow → TBD as the automation improves, not the other way round.

  1. A decision tree: which one to choose

flowchart TD
    A["Do you maintain several versions<br/>in production at once?"]
    A -->|Yes| B["Planned releases<br/>with a manual QA phase?"]
    A -->|No| C["Do you have reliable<br/>automated tests and CI?"]
    B -->|Yes| D["**Git Flow**<br/>Installable products, mobile,<br/>libraries, embedded"]
    B -->|No| E["**GitHub Flow + support<br/>branches**<br/>from the tags"]
    C -->|No| F["**GitHub Flow**<br/>and make building the test<br/>suite the priority"]
    C -->|Yes| G["Does CI take less<br/>than 10 minutes?"]
    G -->|No| H["**GitHub Flow**<br/>and work on speeding up CI<br/>before taking the step"]
    G -->|Yes| I["Can the team decompose work<br/>and remove flags?"]
    I -->|No| J["**GitHub Flow**<br/>with ever shorter branches<br/>as a transition"]
    I -->|Yes| K["**Trunk Based**<br/>Web, SaaS, continuous<br/>deployment, mature teams"]

And four practical criteria that are worth more than any diagram:

1. Choose the simplest model that solves your real problem. Do not adopt release branches "in case one day we need them". Add the piece the day you need it, as the task-manager team did with support/1.4.

2. The models get mixed. Hardly any team applies one of these three in its canonical form. GitHub Flow with a support branch, TBD with quarterly release branches, Git Flow without develop. What matters is that the whole team knows what the agreement is, not that it has a name of its own.

3. The real constraint is almost never Git. It is how long the tests take, how long reviewing takes and how much deploying costs. If you want to integrate more often and cannot, the bottleneck is in one of those three places, not in the branching policy.

4. Write it down. The agreement should be in the repository's CONTRIBUTING.md: which branches exist, where they are born, how they are integrated, which merge method is used and what has to be satisfied in order to merge. A model that lives only in three people's heads does not survive the fourth hire.

Common Mistakes and Tips

Mistake 1: adopting TBD without automated tests. That is not TBD, it is pushing unchecked code straight to production. Build the net first.

Mistake 2: calling week-long branches TBD. If the branch lives for days, it is GitHub Flow. That is fine, but it is not this, and you will not get the reduction in conflicts.

Mistake 3: creating flags and never removing them. Flag debt makes the code unmanageable and ends up being worse than the long branches it was avoiding.

Mistake 4: nested flags. if (A && !B) ... else if (B && C) ... is impossible to test and to reason about. One flag, one decision point.

Mistake 5: using flags to swap one implementation for another in twenty places. That is what branch by abstraction is for: a single flag at a single point.

Mistake 6: not changing the way you decompose work. TBD with the mindset of "I finish the feature and then I integrate" does not work. You have to learn to break things into integrable steps.

Mistake 7: living with slow CI. It is the most common cause of TBD failing. If CI takes forty minutes, the model is unviable however much enthusiasm you throw at it.

Mistake 8: fixing things directly on the release branch. The fix is lost as far as the trunk is concerned and it reappears in the next version. Fix on the trunk and take it over with cherry-pick.

Mistake 9: choosing a model because it is fashionable. TBD because Google uses it, when your team is two people with no automated tests. Google also has a thousand engineers dedicated to the infrastructure that makes it possible.

Tip 1: measure the lifetime of your branches. git for-each-ref --sort=committerdate --format='%(committerdate:short) %(refname:short)' refs/remotes/origin/ gives you the picture in one line. If the average is two weeks, you know where to start.

Tip 2: document every flag with a ticket, an author and an expiry date. In the file itself, next to the declaration.

Tip 3: create the removal ticket when you create the flag. Not when you release: when you create it.

Tip 4: a gradual transition. Going from two-week branches to daily integration in one leap does not work. Cut it down to a week, then to three days, then to one, fixing whatever breaks at each step.

Tip 5: git pull --rebase as a habit. With frequent integrations it avoids dozens of useless merge commits. Configure it with git config --global pull.rebase true (lesson 01-06).

Tip 6: write the agreement in CONTRIBUTING.md. With examples of specific commands, not just the theory.

Exercises

Exercise 1: the real cost of divergence

Demonstrate empirically why integrating often reduces conflicts.

  1. Create a repository with a twenty-line numbered app.js.
  2. Scenario A (large divergence): create two branches and make six commits on each, touching nearby lines of the same file. Merge them and count the conflicts.
  3. Scenario B (frequent integration): start from the same initial state in a copy. Alternate: one commit on branch 1, merge into main; one commit on branch 2 rebased onto main, merge; and so on up to six on each.
  4. Compare the number of conflicts and of conflicting lines between the two scenarios.
  5. Provoke a semantic conflict: on one branch rename a function and on the other add a call under the old name. Check that Git merges without complaining and that the result is broken.

Exercise 2: feature flags

  1. Create config/flags.js with two documented flags (ticket, author, expiry date).
  2. Implement in app.js a new feature hidden behind a switched-off flag, in three commits integrated into main one by one (skeleton, logic, presentation). Check that at each step the application still works with the flag off.
  3. "Release" the feature with a commit that only changes false to true.
  4. Simulate an incident: roll back the release with a commit that puts false back, and compare it with what rolling back the whole code would have cost.
  5. Remove the flag: delete the condition and the file entry, in a commit of its own with a message explaining why.
  6. Write a command that lists the file's flags alongside the date each line was last changed.

Exercise 3: branch by abstraction

Migrate task-manager's storage from localStorage to a simulated API, with no long branches. Each step must be a commit on main that leaves the application working.

  1. Initial state: app.js calls localStorage directly.
  2. Step 1: create storage/index.js that re-exports the current implementation, and make app.js use it. No change in behaviour.
  3. Step 2: add storage/api-storage.js with the new implementation (it can be simulated).
  4. Step 3: select the implementation with a flag at a single point.
  5. Step 4: switch the flag on.
  6. Step 5: delete the old implementation and the flag.
  7. Show git log --oneline and check that it is six small, independent steps, each of them deployable.

Solutions

Solution 1:

mkdir /tmp/tbd-conflicts && cd /tmp/tbd-conflicts
git init -qb main
seq 1 20 | sed 's/^/const line/;s/$/ = 0;/' > app.js
git add . && git commit -q -m "Initial state"
cd /tmp && cp -r tbd-conflicts tbd-frequent && cd /tmp/tbd-conflicts
# Scenario A: large divergence
git switch -qc branch-ana
for i in 1 2 3 4 5 6; do
  sed -i "${i}s/= 0;/= ${i}00;  \/\/ ana/" app.js
  git commit -qam "Ana change $i"
done
git switch -q main
git switch -qc branch-bruno
for i in 1 2 3 4 5 6; do
  sed -i "${i}s/= 0;/= ${i}99;  \/\/ bruno/" app.js
  git commit -qam "Bruno change $i"
done
git switch -q main
git merge -q branch-ana
git merge branch-bruno
Auto-merging app.js
CONFLICT (content): Merge conflict in app.js
Automatic merge failed; fix conflicts and then commit the result.
grep -c '^<<<<<<<' app.js      # number of conflicting blocks
git merge --abort
# Scenario B: frequent integration
cd /tmp/tbd-frequent
for i in 1 2 3 4 5 6; do
  git switch -q main
  git switch -qc ana-$i
  sed -i "${i}s/= 0;/= ${i}00;  \/\/ ana/" app.js
  git commit -qam "Ana change $i"
  git switch -q main && git merge -q ana-$i && git branch -qd ana-$i

  git switch -qc bruno-$i
  sed -i "${i}s/\/\/ ana/\/\/ ana bruno/" app.js
  git commit -qam "Bruno change $i"
  git switch -q main && git merge -q bruno-$i && git branch -qd bruno-$i
done
echo "Integrations completed with no conflict"

In scenario B each branch starts from an up-to-date trunk and the divergence never exceeds one commit: there is not a single conflict. The same work, the same file, a different cost.

# 5. Semantic conflict
cd /tmp/tbd-conflicts
git switch -q main
printf 'function saveTasks(t) { return t; }\nsaveTasks([]);\n' > tasks.js
git add . && git commit -q -m "Add tasks.js"
git switch -qc rename
sed -i 's/function saveTasks/function persistTasks/;s/^saveTasks(\[\]);/persistTasks([]);/' tasks.js
git commit -qam "Rename saveTasks to persistTasks"
git switch -q main
git switch -qc new-call
echo 'saveTasks([{id: 1}]);' >> tasks.js
git commit -qam "Add one more call"
git switch -q main
git merge -q rename
git merge new-call
cat tasks.js
function persistTasks(t) { return t; }
persistTasks([]);
saveTasks([{id: 1}]);

Git merges without a conflict because the lines are different, and the result calls a function that no longer exists. Only the tests catch it, and only if they exist.

Solution 2:

mkdir /tmp/tbd-flags && cd /tmp/tbd-flags
git init -qb main
mkdir config
cat > config/flags.js <<'EOF'
// task-manager feature flags.
export const FLAGS = {
  // GT-352 — Ana Ferrer — created 2026-08-03 — remove before 2026-09-01
  statsPanel: false,

  // GT-338 — Bruno Salas — created 2026-07-20 — remove before 2026-08-15
  csvExport: true,
};
EOF
printf "import { FLAGS } from './config/flags.js';\n\nfunction renderSidebar() {\n  const sidebar = document.querySelector('#sidebar');\n  sidebar.innerHTML = '';\n}\n" > app.js
git add . && git commit -q -m "Add the feature flags file"
# 2. Three steps, three integrations, deployable throughout
cat >> app.js <<'EOF'

function buildStatsPanel() {
  const panel = document.createElement('section');
  panel.className = 'stats-panel';
  return panel;
}
EOF
git commit -qam "Add the skeleton of the statistics panel (GT-352)"

sed -i "s|  panel.className = 'stats-panel';|  panel.className = 'stats-panel';\n  panel.dataset.total = String(calculateTotals().total);|" app.js
cat >> app.js <<'EOF'

function calculateTotals() {
  return { total: 0, completed: 0 };
}
EOF
git commit -qam "Calculate the statistics panel totals (GT-352)"

sed -i "s|  sidebar.innerHTML = '';|  sidebar.innerHTML = '';\n  if (FLAGS.statsPanel) {\n    sidebar.append(buildStatsPanel());\n  }|" app.js
git commit -qam "Show the statistics panel behind its flag (GT-352)"
# 3. Release: one line
sed -i 's/statsPanel: false,/statsPanel: true,/' config/flags.js
git commit -qam "Enable the statistics panel for all users

Closes GT-352 (release)."
# 4. Emergency rollback: another line
sed -i 's/statsPanel: true,/statsPanel: false,/' config/flags.js
git commit -qam "Disable the statistics panel: calculation error in the totals

Incident GT-360."

Rolling back the whole code would have required a git revert of three commits, resolving the conflicts with whatever had been integrated afterwards, building and deploying. Switching off the flag is a one-line change that does not touch the logic.

# 5. Removal (after re-enabling and stabilising)
sed -i 's/statsPanel: false,/statsPanel: true,/' config/flags.js
git commit -qam "Re-enable the statistics panel after fixing the totals"

python3 - <<'EOF'
import re
s = open('app.js').read()
s = s.replace("""  if (FLAGS.statsPanel) {
    sidebar.append(buildStatsPanel());
  }""", "  sidebar.append(buildStatsPanel());")
open('app.js','w').write(s)
EOF
sed -i '/GT-352/,+1d' config/flags.js
git commit -qam "Remove the statsPanel flag

It has been on at 100% for three weeks with no incidents. The condition
no longer adds anything and it removes a code path to test. Closes GT-352."
# 6. Flag audit
git blame --date=short -- config/flags.js | grep -E ': (true|false),'

Solution 3:

mkdir /tmp/tbd-abstraction && cd /tmp/tbd-abstraction
git init -qb main
mkdir -p storage config
printf "function save(t) { localStorage.setItem('tasks', JSON.stringify(t)); }\nfunction load() { return JSON.parse(localStorage.getItem('tasks') || '[]'); }\n" > app.js
echo "export const FLAGS = {};" > config/flags.js
git add . && git commit -q -m "Initial state: storage in localStorage"
# Step 1: the abstraction, with no change in behaviour
cat > storage/local-storage.js <<'EOF'
export function saveTasks(t) { localStorage.setItem('tasks', JSON.stringify(t)); }
export function loadTasks() { return JSON.parse(localStorage.getItem('tasks') || '[]'); }
EOF
cat > storage/index.js <<'EOF'
import * as local from './local-storage.js';
export const saveTasks = local.saveTasks;
export const loadTasks = local.loadTasks;
EOF
printf "import { saveTasks, loadTasks } from './storage/index.js';\n\nexport { saveTasks, loadTasks };\n" > app.js
git add . && git commit -q -m "Introduce the storage abstraction layer

No change in behaviour: index.js re-exports the current implementation
based on localStorage. It prepares the migration to the server."
# Step 2: the new implementation, unused
cat > storage/api-storage.js <<'EOF'
export async function saveTasks(t) {
  await fetch('/api/tasks', { method: 'PUT', body: JSON.stringify(t) });
}
export async function loadTasks() {
  const r = await fetch('/api/tasks');
  return r.ok ? r.json() : [];
}
EOF
git add . && git commit -q -m "Add the storage implementation against the API

Nobody uses it yet: index.js still points at localStorage."
# Step 3: the flag, at a single point
cat > config/flags.js <<'EOF'
export const FLAGS = {
  // GT-377 — Ana Ferrer — created 2026-08-05 — remove after the migration
  serverStorage: false,
};
EOF
cat > storage/index.js <<'EOF'
import { FLAGS } from '../config/flags.js';
import * as local from './local-storage.js';
import * as api   from './api-storage.js';

const impl = FLAGS.serverStorage ? api : local;

export const saveTasks = impl.saveTasks;
export const loadTasks = impl.loadTasks;
EOF
git add . && git commit -q -m "Select the storage implementation with a flag (GT-377)"
# Step 4: switch it on
sed -i 's/serverStorage: false,/serverStorage: true,/' config/flags.js
git commit -qam "Enable server storage for all users (GT-377)"

# Step 5: remove the old one
git rm -q storage/local-storage.js
cat > storage/index.js <<'EOF'
export { saveTasks, loadTasks } from './api-storage.js';
EOF
echo "export const FLAGS = {};" > config/flags.js
git add . && git commit -q -m "Remove localStorage storage and its flag

The migration has been on at 100% for two weeks. Closes GT-377."
# 7. Six steps, all deployable
git log --oneline
f2a9c1e Remove localStorage storage and its flag
8d3b7f4 Enable server storage for all users (GT-377)
1c6e0a9 Select the storage implementation with a flag (GT-377)
5b9d2f7 Add the storage implementation against the API
3e7a4c8 Introduce the storage abstraction layer
9f1c5b2 Initial state: storage in localStorage

A complete architectural migration, with no branch living longer than a few hours, and with every step independently deployable.

Conclusion

Trunk Based Development is the extreme of frequent integration, and its value lies in understanding why that frequency matters. The essentials:

  • The rule: everybody integrates into the trunk at least once a day. Branches of hours, or direct commits in very small teams.
  • The real goal is not to have few branches: it is to reduce the time between integrations, because the cost of a conflict grows much faster than the time spent diverging. And semantic conflicts — the ones Git does not detect — grow just the same.
  • Continuous integration is not a CI server: it is genuinely integrating, and often. TBD is the branching policy that makes it possible.
  • The technique that allows it is separating deployment from release with feature flags: the code travels to production switched off, it is released by changing one line and it is rolled back by switching it off. Distinguish temporary flags (release and experiment, which must be removed) from permanent ones (operational and permission).
  • Flags have a real cost: flag debt multiplies the paths through the code. A documented expiry date, a removal ticket created when they are created, removal as part of the task, and periodic auditing.
  • To replace one implementation with another, branch by abstraction: an abstraction, the new implementation, a flag at a single point, progressive migration, removal of the old one. Five steps, no long branches.
  • Release branches exist only when they are needed, and the direction is always fix on the trunk and take the fix over with cherry-pick, never the other way round.
  • It demands fast (under ten minutes) and reliable CI, a culture of small commits, quick review, a healthy trunk as the absolute priority and maturity in managing flags.
  • And the reading from the comparison: the complexity does not disappear, it moves. Git Flow puts it in the branch structure; TBD puts it in the code and the automation. The less reliable your automation, the more branch structure you need as a safety net.

All three models, for all their differences, depend on the same thing: automated checks you can trust. Git Flow needs them least because it has manual QA; GitHub Flow requires them so that main is deployable; TBD requires them fast and flawless because the trunk receives changes every hour. Without them, no flow works.

It is finally time to build that piece. In lesson 07-06: Continuous Integration with Git we shall see what continuous integration really is, how it hooks into Git through triggers, which commit is checked exactly in a pull request — the answer is surprising — how status checks and protected branches become a requirement that nobody can bypass with a --no-verify, and we shall finally pick up the server-side hooks that lesson 06-01 left pending.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved