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
- The idea and where it comes from
- Why the real goal is reducing the time between integrations
- The model's two variants
- Separating deployment from release: feature flags
- The cost of flags: flag debt
- Branch by abstraction: large changes without long branches
- Release branches only when they are needed
- What TBD demands
- Final comparison of the three flows
- A decision tree: which one to choose
- 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".
- 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.
- 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 pushThe 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.
- 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
falsetotrue: a one-line change, with no deployment of new code. - And if something goes wrong, switching it off is changing
truetofalse: the fastest and least risky rollback there is. No hurriedgit revertat 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;
}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.
- 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.
- 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.
- 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-tagsAnd 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-tagsThis 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.
- 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.
- 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.
- 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.
- Create a repository with a twenty-line numbered
app.js. - 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.
- 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 ontomain, merge; and so on up to six on each. - Compare the number of conflicts and of conflicting lines between the two scenarios.
- 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
- Create
config/flags.jswith two documented flags (ticket, author, expiry date). - Implement in
app.jsa new feature hidden behind a switched-off flag, in three commits integrated intomainone by one (skeleton, logic, presentation). Check that at each step the application still works with the flag off. - "Release" the feature with a commit that only changes
falsetotrue. - Simulate an incident: roll back the release with a commit that puts
falseback, and compare it with what rolling back the whole code would have cost. - Remove the flag: delete the condition and the file entry, in a commit of its own with a message explaining why.
- 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.
- Initial state:
app.jscallslocalStoragedirectly. - Step 1: create
storage/index.jsthat re-exports the current implementation, and makeapp.jsuse it. No change in behaviour. - Step 2: add
storage/api-storage.jswith the new implementation (it can be simulated). - Step 3: select the implementation with a flag at a single point.
- Step 4: switch the flag on.
- Step 5: delete the old implementation and the flag.
- Show
git log --onelineand 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-brunoAuto-merging app.js CONFLICT (content): Merge conflict in app.js Automatic merge failed; fix conflicts and then commit the result.
# 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.jsGit 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."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."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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
