Your team has three developers and four different opinions about git branching.
One person wants Gitflow. Another saw a diagram of GitHub Flow on a blog post from 2019 and pinned it in Slack. The third developer commits straight to main and hopes for the best. The codebase, predictably, is a mess.
Here’s the thing: the right git workflow for small teams is the one with the least ceremony that still keeps production safe. That’s trunk-based development with feature flags. I’ve shipped production code this way with teams of two, teams of eight, and everything in between. It works. Let me show you exactly how to set it up.
Why Most Git Workflows Are Built for Teams You’re Not On
Gitflow was designed in 2010 for teams shipping versioned software on a release schedule. If you’re deploying a desktop app quarterly, it makes sense. You need develop, release/1.2, hotfix/urgent-patch, the whole tree.
You’re probably not doing that.
Most small teams in 2026 are shipping web apps. Deploys happen on merge. There’s one production environment, maybe a staging environment, and that’s it. In this world, Gitflow’s develop branch is a merge conflict factory. Long-lived feature branches drift from main for days or weeks. When they finally merge, you spend Friday afternoon resolving conflicts instead of shipping.
The DORA research backs this up. Teams with shorter branch lifetimes deploy more often, recover from failures faster, and have lower change failure rates. The data is clear: long-lived branches are a liability.
I’ve watched a three-person startup grind to a halt because two developers were merging week-old feature branches on the same Friday. Both branches touched the auth module. The merge took longer than writing the features did. That’s not a tooling problem. That’s a workflow problem.
Trunk-based development eliminates this entirely. Everyone works off main. Branches live for hours, not days. And feature flags handle the “but my feature isn’t done yet” problem that branches were solving badly in the first place.
The Workflow: Trunk-Based with Short-Lived Branches
Here’s the entire git workflow for small teams. It fits on an index card.
- Pull
main. - Create a short-lived branch.
- Make small, focused commits.
- Open a PR. Get one review.
- Merge to
main. Delete the branch. - Deploy automatically.
That’s it. No develop. No release/*. No hotfix/*. One branch to rule them all.
The Branch
git checkout main
git pull origin main
git checkout -b nb/add-user-avatar
The prefix is your initials. The name describes the change, not the ticket. nb/add-user-avatar tells a reviewer what’s coming. nb/JIRA-4521 tells them nothing.
Keep the branch alive for one day. Two days maximum. If your feature takes longer than that, it’s too big. Break it down.
The Commits
Small commits. Each one should compile and pass tests on its own.
git add src/components/Avatar.tsx src/components/Avatar.test.tsx
git commit -m "add Avatar component with fallback initials"
git add src/api/uploadAvatar.ts
git commit -m "add avatar upload endpoint handler"
git add src/pages/Profile.tsx
git commit -m "wire avatar upload to profile page"
Notice something. Each commit message starts with a lowercase verb. No ticket numbers in commits. No “WIP.” No “fix stuff.” Every message answers the question: “What does this commit do to the codebase?”
If you want to learn how to structure code for reviewability, that’s a separate discipline. But good commits are half the battle.
The PR
git push origin nb/add-user-avatar
Open the PR in your Git host. Write a one-paragraph description of what changed and why. Add a screenshot if there’s a UI change. Request one reviewer.
One reviewer. Not two, not three. For a small team, one approval is the right tradeoff between safety and speed. If you don’t trust a single teammate to catch problems, you have a hiring problem, not a process problem.
The reviewer should respond within four hours. If PRs sit for a day, your workflow is already broken. Stale PRs are where momentum goes to die.
The Merge
# On GitHub/GitLab, use the "Squash and merge" button
# Or from the command line:
git checkout main
git merge --squash nb/add-user-avatar
git commit -m "add user avatar upload to profile page"
git push origin main
git branch -d nb/add-user-avatar
git push origin --delete nb/add-user-avatar
Squash merging is a deliberate choice. Your branch had five commits of incremental work. main gets one clean commit that describes the whole change. The history stays readable. Six months from now, git log --oneline tells a story, not a diary.
Delete the branch after merge. Every time. No exceptions.
Dead branches clutter the repo and confuse new teammates. I once joined a project with 140 remote branches. Twelve were active.
The rest were ghosts of features past, and nobody could tell which was which. Set your Git host to auto-delete branches on merge. It takes 30 seconds to configure and saves you from this graveyard.
Feature Flags: Ship Incomplete Code Safely
Here’s the question trunk-based development forces you to answer: what happens when your feature takes a week but you’re merging to main daily?
Feature flags.
A feature flag wraps unfinished code in a conditional. The code ships to production. Users don’t see it. When the feature is ready, you flip the flag. No deploy needed.
Here’s a minimal implementation in TypeScript:
// flags.ts
type FeatureFlags = {
userAvatars: boolean;
newDashboard: boolean;
betaSearch: boolean;
};
const flags: FeatureFlags = {
userAvatars: false,
newDashboard: false,
betaSearch: false,
};
export function isEnabled(flag: keyof FeatureFlags): boolean {
return flags[flag];
}
Using it in a component:
import { isEnabled } from "../flags";
export function ProfileHeader({ user }: { user: User }) {
return (
<div className="sw-profile-header">
<h1>{user.name}</h1>
{isEnabled("userAvatars") && (
<Avatar src={user.avatarUrl} fallback={user.initials} />
)}
</div>
);
}
The Avatar component is in production. It’s deployed. It’s behind a flag set to false. Nobody sees it. You can merge avatar-related code to main every day for a week without affecting users.
When it’s ready, you change one value:
const flags: FeatureFlags = {
userAvatars: true, // shipped 2026-03-03
// ...
};
Commit, push, deploy. Feature is live.
This is the key insight that makes trunk-based development practical. You decouple “deploying code” from “releasing features.” Deploys are mechanical and frequent. Releases are intentional decisions made when the feature is ready, tested, and reviewed. That separation changes how your team thinks about shipping.
When to Use a Flag Service Instead
The hardcoded flags file works for teams of 2-5 shipping one product. Once you need per-user targeting, gradual rollouts, or instant kill switches without deploying, use a flag service. LaunchDarkly, Unleash, and Flipt are solid options. Unleash is open source if budget matters.
But start with the file. You can migrate later. Premature infrastructure is a real cost. If you’re interested in keeping things pragmatic with TypeScript on the backend, the typed flags approach above scales further than you’d expect.
The Rules That Make This Work
Trunk-based development looks simple on paper. In practice, it needs three ground rules or it falls apart.
Rule 1: No Branch Lives Longer Than Two Days
This is the hardest rule and the most important one. If a branch is open for three days, something went wrong. Either the task was too big, the review took too long, or someone got pulled onto something else.
When a branch gets stale, don’t try to rescue it. Pull main, rebase, and force push:
git checkout nb/stale-feature
git fetch origin
git rebase origin/main
# resolve any conflicts
git push --force-with-lease origin nb/stale-feature
--force-with-lease is non-negotiable here. It prevents you from overwriting a teammate’s push. Regular --force is a footgun.
Rule 2: Main Is Always Deployable
Every commit on main must pass CI. No exceptions, no “I’ll fix it in the next commit.” If CI is red, everything stops until it’s green.
This means your CI pipeline needs to be fast. Under 10 minutes. If your test suite takes 30 minutes, trunk-based development will feel painful because you’re merging to main multiple times a day. Fix the tests first. Parallelize them. Drop the flaky ones. A fast, reliable CI pipeline isn’t optional here. It’s infrastructure.
A minimal CI config for this workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
Lint, typecheck, test. In that order. Fail fast on the cheapest check first.
Rule 3: Feature Flags Get Cleaned Up
Feature flags have a shelf life. Once a feature is stable in production (give it a week or two), remove the flag and the conditional. Dead flags are dead code.
Add a comment with a cleanup date:
const flags: FeatureFlags = {
userAvatars: true, // shipped 2026-03-03 — remove flag by 2026-03-17
newDashboard: false,
betaSearch: false,
};
I’ve seen codebases with 200+ stale feature flags. The logic becomes unreadable. Every if (isEnabled(...)) is a branch in your code that doubles the paths someone has to reason about. Ship it, verify it, clean it up.
Handling Hotfixes Without a Hotfix Branch
In Gitflow, hotfixes get their own branch off main, then merge back into both main and develop. In trunk-based development, there’s nothing special about a hotfix. It’s the same workflow.
git checkout main
git pull origin main
git checkout -b nb/fix-login-crash
# fix the bug
git add src/auth/login.ts
git commit -m "fix null check on OAuth callback response"
git push origin nb/fix-login-crash
Open a PR. Get a fast review. Merge. Deploy.
The difference is urgency, not process. For a critical production bug, ping your reviewer directly instead of waiting. Skip the 4-hour review window.
But the mechanics are identical. Same workflow, same safety checks, same CI pipeline.
This is the advantage of keeping things simple. When production is on fire at 11 PM, you don’t have to remember which branch to base your fix on. It’s always main. You don’t need to check which release branch is current. You don’t need to merge forward into develop afterward. You fix, you merge, you deploy, you go back to bed.
What This Looks Like After a Month
Here’s a real git log --oneline from a team of four running this workflow:
a3f1d2c add search result pagination
b7e4a1f fix avatar upload on Safari
c9d2b3e enable userAvatars flag for production
d1a5c4f add avatar crop and resize on upload
e8b3f2a add Avatar component with fallback initials
f2c7d1b update dashboard query to include archived items
g5a8e3c fix timezone offset in activity feed
h1d4f7a add activity feed to project dashboard
Clean. Readable. Every commit is a complete change. You can git revert any single commit without untangling a web of dependencies. You can git bisect to find when a bug was introduced. The history is an asset, not noise.
Compare that to a Gitflow history full of “Merge branch ‘develop’ into release/1.3” and “Merge branch ‘release/1.3’ into main” and “Merge branch ‘main’ into develop.” You can’t read it. You definitely can’t bisect it.
The Bottom Line
You started with three developers and four opinions about branching. Here’s the one that ships.
Work off main. Branch short, merge fast. Use feature flags for anything that takes more than a day. Keep CI green. Clean up your flags.
That’s a git workflow for small teams that scales from two devs to twelve without changing the process. No ceremonies, no branch naming committees, no merge day. Code goes from your editor to production in hours, not weeks.
The teams that ship fastest aren’t the ones with the cleverest branching strategy. They’re the ones with the simplest one.