Skip to content
M04Git for investigating and undoingLesson 1 of 3

Branching strategy

Why long-lived branches cause the worst merges, and how trunk-based development, small PRs and feature flags keep main deployable every day.

Goal
Keep main deployable at all times by merging small changes every day and hiding unfinished work behind a flag instead of a branch.

Linkr’s custom domains feature lived on feature/custom-domains for five weeks. In that time main got 140 commits: a refactor of the link model, two migrations and a rewrite of the redirect middleware.

Merge day brought 38 conflicts. The AI assistant resolved most of them by keeping “both sides”, the tests passed and the merge commit went in with 4,000 changed lines. Nobody reviewed it line by line, because nobody could.

In production, expired links started redirecting again. An if that checked expires_at had disappeared during conflict resolution. Both branches were correct on their own; the bug existed only in the merge commit.

While a branch is open, main keeps moving. The longer you wait, the more both sides have changed the same code, and the harder each conflict is to resolve correctly. The risk piles up and arrives all at once, usually right before a launch.

gitGraph
  commit id: "a"
  branch feature/custom-domains
  commit id: "domains 1"
  checkout main
  commit id: "refactor links"
  commit id: "migration"
  checkout feature/custom-domains
  commit id: "domains 2"
  checkout main
  commit id: "redirect rewrite"
  checkout feature/custom-domains
  commit id: "domains 3"
  checkout main
  merge feature/custom-domains id: "38 conflicts"

Review degrades with size too. A 50-line PR gets read; a 2,000-line PR gets “LGTM”. The big merge ends up being the riskiest change of the month and the one that gets the least attention.

The alternative is to integrate continuously. Everyone merges into main (the trunk) at least once a day. Branches still exist, but they live for hours or a day or two. Every merge passes CI, main is always deployable, and production deploys come from main. Work that isn’t finished goes in turned off, behind a feature flag, instead of waiting on a branch.

The DORA research links this practice to teams that deploy more often and with fewer failures. Its markers are three or fewer active branches, merges at least daily and no code freezes.

Long-lived branches (Git Flow) Trunk-based
Integration At the end, all at once Every day, in small pieces
Conflicts Large and rare Small and frequent (easy)
Review Huge PRs PRs of a few hundred lines
Unfinished feature Waits on the branch Goes to main, turned off
Made for Versioned releases (desktop, libraries) Continuously deployed services

Git Flow made sense for software shipped in versions, where several releases had to be maintained in parallel. A web app with a single version in production has no use for develop or release/* branches, which only delay integration.

Merging every day only works if you know how to cut work into pieces that are safe to merge alone. These cuts come up all the time:

  • Separate refactoring from behavior changes. First a PR that moves code and changes nothing, which is easy to review because the tests stay the same. Then a PR that changes behavior.
  • Change the database first and the code after. One PR adds the nullable column, the next writes to it, the next reads from it. This is the expand/contract pattern from M12, and it also makes each step easy to undo.
  • Ship the backend before the UI. A new endpoint can go to production with no screen calling it.
  • Build a vertical slice behind a flag: the smallest end-to-end version of the feature, turned off for everyone except you.

A reasonable target is one reason to change per PR and a few hundred lines at most. If the PR description needs the word “also”, it’s probably two PRs.

A flag separates deploy (the code is in production) from release (users see it). Custom domains can be merged in ten small PRs over two weeks, all turned off, and then turned on for internal users first, then for 10% of Pro customers, then for everyone.

You don’t need a vendor to start. A table and a function will do:

CREATE TABLE feature_flags (
name text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false, -- on for everyone
user_ids text[] NOT NULL DEFAULT '{}', -- on for these users
percentage integer NOT NULL DEFAULT 0 CHECK (percentage BETWEEN 0 AND 100)
);
INSERT INTO feature_flags (name, user_ids) VALUES ('custom-domains', '{"7","12"}');
import { createHash } from 'node:crypto';
export async function isEnabled(flag: string, userId: number): Promise<boolean> {
const { rows } = await db.query(
'SELECT enabled, user_ids, percentage FROM feature_flags WHERE name = $1',
[flag],
);
const f = rows[0];
if (!f) return false; // unknown flag = off, never on
if (f.enabled || f.user_ids.includes(String(userId))) return true;
// Same user, same answer on every request. The flag name in the hash
// keeps the same 10% of users from getting every experiment.
const bucket = createHash('sha256').update(`${flag}:${userId}`).digest().readUInt32BE(0) % 100;
return bucket < f.percentage;
}
app.get('/api/domains', async (req, res) => {
if (!(await isEnabled('custom-domains', req.user.id))) {
return res.status(404).end(); // check on the server too, not just in the UI
}
// ...
});

Changing a flag is an UPDATE, with no deploy. In production, cache the flag table in memory for a few seconds instead of querying it on every request (M25). Once you need targeting rules, audit logs and a screen non-developers can use, tools like Unleash, GrowthBook or LaunchDarkly do the same job with more features.

Flags don’t all live the same amount of time:

Type Example Lifespan
Release custom-domains while it’s being built Days to weeks. Remove after full rollout.
Kill switch Turn off click analytics if the queue backs up Permanent, and documented
Experiment Two versions of the pricing page Until the test has a result
Permission Feature only for the Enterprise plan Permanent, but that’s really authorization (M16)

In practice: protect main and find old branches

Section titled “In practice: protect main and find old branches”

On GitHub, set up a ruleset (or branch protection) on main that requires a pull request before merging, so nobody pushes directly, and requires the CI checks (tests, lint, types) to pass. In the repository settings, turn on automatic deletion of branches after merge.

To see which branches are getting old:

Terminal window
git fetch --prune
git for-each-ref --sort=committerdate refs/remotes/origin \
--format='%(committerdate:relative)%09%(refname:short)'

And to see the size of your recent PRs:

Terminal window
gh pr list --state merged --limit 15 --json number,additions,deletions,title \
--jq '.[] | "\(.additions + .deletions)\t#\(.number) \(.title)"'
  • Flags that never leave. Every release flag is a fork in the code that has to be tested both ways. When you create one, also create the ticket to remove it. Knight Capital lost $460 million partly because an old flag was reused with a new meaning (M01).
  • Testing only the “on” path. While the flag exists, both states have to work. If you switch it off in an emergency, the “off” path is what runs.
  • Expecting flags to protect the database. A migration runs for everyone, flag or no flag. Schema changes still need to be backward compatible (M12).
  • Trunk-based without CI. Merging to main every day with no tests means breaking main every day. The practice depends on a pipeline that blocks bad merges (M22).
  • What AI tends to get wrong: asked to “implement custom domains”, an agent produces a 30-file PR in one go. Ask for the plan first and implement it one PR at a time. When resolving conflicts, AI often keeps both sides or picks one without understanding either, so read every resolved hunk.
  1. Run the old-branches command. For each branch older than a week, decide whether to merge it in pieces behind a flag or delete it.
  2. Run the PR-size command. If most PRs are above 500 lines, try one of the cuts above on the next feature.
  3. Turn on protection for main if it isn’t on yet.

Go deeper

Materials in English unless noted.