The 5 Rules of Git Branching for High-Velocity Teams
Practical constraints for code integration that actually work. Less about naming conventions, more about not breaking production.

The argument that never ends
I have sat in more meetings about branching strategies than I want to admit. Hours of collective engineering time spent debating naming conventions, whether to use develop or not, what "release branches" should look like, who can push where. And every time, the conversation generates way more heat than it should for something that is, at the end of the day, a workflow preference.
Here is what I have actually noticed over the years: teams that ship well and teams that don't โ the difference is almost never which branching model they picked off a blog post. It's whether they agreed on anything at all and then stuck with it. A mediocre branching strategy that everyone follows beats a theoretically perfect one that half the team ignores.
That said, I do have opinions. Strong ones about some things, less certain about others. I have worked on two-person startups and on teams with forty-plus engineers touching the same monorepo. The branching approach that works changes depending on context, and anyone telling you otherwise is selling something.
So instead of giving you rules, I want to walk through the main approaches, say what I honestly think about each, and tell you what I actually do. Take it or leave it.
GitFlow: the one everyone learns first
Vincent Driessen published the GitFlow model back in 2010 and it spread fast. Two long-lived branches โ main and develop. Feature branches come off develop, get merged back in. Release branches get cut from develop, go through stabilization, then merge into both main and develop. Hotfix branches come off main for emergency patches.
It looks beautiful on a diagram. Honestly, it does.
In practice, I have had a rough time with it on most teams I have been on. The ceremony is heavy. You end up with this constant bookkeeping of making sure merges go to the right places, that develop and main don't drift apart in weird ways, that release branches get properly closed out. I have seen teams where develop was weeks ahead of main and nobody could confidently say what was actually in production.
Where I think GitFlow still makes sense: if you are shipping versioned software. Desktop apps, mobile apps with app store review cycles, libraries that need to maintain multiple supported versions. In those cases, the release branch concept maps to something real. You actually need a place to stabilize a release while new feature work continues.
But for a web application that deploys multiple times a day? GitFlow is overhead you don't need. I have watched teams adopt it because it seemed "professional" and then spend half their standup talking about merge logistics instead of actual product work.
One thing I will say in GitFlow's defense โ it does force people to think about what "a release" means. Teams that go straight to trunk-based development sometimes lose that discipline entirely, and I'm not sure that's always better. More on that later.
GitHub Flow: keep it simple
GitHub Flow strips things way down. You have main. It is always deployable. You make a branch, you do your work, you open a pull request, someone reviews it, it gets merged back into main, and you deploy. That's it.
git checkout -b add-invoice-export
# do work, commit, push
git push -u origin add-invoice-export
# open PR, get review, merge, deploy
I genuinely like this model for most web teams. It is easy to explain to a new hire. There is no ambiguity about where code goes or what state any given branch is in. main is production. Everything else is in-flight work.
The thing that makes or breaks GitHub Flow is your CI pipeline. If merging to main means deploying, then your tests and checks before merge need to be good. Really good. Because you have no staging branch, no release stabilization period, nothing between "this PR got approved" and "this is in production."
Some teams bolt on environment branches โ staging, qa, whatever โ and at that point you are kind of reinventing parts of GitFlow but without the formal model. I have done this and it works okay. It's a bit messy conceptually. But it works.
The honest downside of GitHub Flow: it assumes your PRs are small and your merge-to-deploy pipeline is fast. If a PR takes four days to review and another day to get through a slow CI pipeline, you are going to have stale branches and painful merges no matter what you call your strategy.
Trunk-based development: the one that scares people
Trunk-based development (TBD) says: everyone commits to main (or trunk). Either directly, or through very short-lived branches that last hours, not days. The goal is continuous integration in the literal sense โ your code integrates with everyone else's code constantly.
This is what Google does at scale. It is what many high-performing teams practice. And it makes a lot of engineers uncomfortable.
The discomfort is understandable. "Everyone pushes to main" sounds like chaos if you have been burned by broken builds and untested code hitting production. But TBD does not mean "push whatever you want and hope for the best." It means:
- Strong automated testing that runs before and after every commit
- Feature flags to hide incomplete work from users
- Small, incremental changes instead of big-bang feature branches
- Pair programming or post-commit review instead of (or in addition to) pre-merge PR review
# A typical TBD workflow
git pull --rebase origin main
# make a small, focused change
git add -p
git commit -m "Add currency formatting to invoice totals"
git push origin main
Or with short-lived branches:
git checkout -b anurag/currency-format
# a few hours of work, maybe a day at most
git push -u origin anurag/currency-format
# quick review, merge same day, delete branch
I think trunk-based development is probably the right answer for most experienced teams working on web applications. Probably. I hedge because I have also seen it go wrong when the testing infrastructure was not there to support it. If your test suite takes forty minutes and is flaky, TBD will hurt. People will push broken code, the build will be red half the time, and everyone will be miserable.
The feature flag part also adds real cost. You need a way to manage flags, clean up old ones, test different flag combinations. It is not free. I have seen codebases where dead feature flags accumulated for years because nobody wanted to touch them. That's its own kind of technical debt.
But when it works โ when the tests are fast, the flags are managed, and the team is disciplined โ the speed is hard to beat. You basically eliminate merge conflicts because there's nothing to diverge from. Integration pain goes to near zero.
The stuff that matters regardless of model
Whatever you pick, a few things hold true. These are not really branching strategy decisions; they are just git hygiene that I think every team should care about.
Keep main deployable
This one I will be absolute about. If main is broken, everything downstream breaks. CI should gate merges. Nobody should be able to push directly to main without checks passing. If you are on GitHub, branch protection rules are free and take five minutes to set up. There is no excuse.
# A basic GitHub Actions check
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- run: npm run build
I have been on exactly one team that decided branch protection was "too much process" and that everyone was senior enough to self-police. It lasted about three weeks before someone pushed a syntax error to main on a Friday afternoon and the on-call person spent their evening fixing it. We added branch protection the following Monday.
Smaller changes, more often
I don't care if you are doing GitFlow, GitHub Flow, or TBD. The single highest-impact thing you can do is make your changes smaller. A 50-line PR gets reviewed in ten minutes and merges cleanly. A 2,000-line PR sits in review for a week, collects nitpick comments, and becomes a merge conflict nightmare.
This is harder than it sounds, by the way. Breaking a big feature into small, independently shippable pieces is a real skill. It requires thinking about the work differently โ not "build the whole feature and then submit it" but "what is the smallest change that moves us forward and leaves the codebase in a good state?"
Sometimes that means shipping backend changes before the frontend is ready. Sometimes it means adding a database migration in one PR and the code that uses it in the next. Sometimes it means merging code that is technically dead until a flag is flipped.
It feels slower in the moment. It's faster overall. I am convinced of this even though I still catch myself building too much before opening a PR sometimes.
Rebase vs. merge: my actual take
I have gone back and forth on this more than I'd like to admit. For years I was a rebase purist โ linear history, clean graph, git bisect works perfectly. I would get annoyed at merge commits cluttering up the log.
These days I'm less dogmatic. Here is where I have landed:
For keeping a feature branch up to date with main, I still prefer rebase.
git fetch origin
git rebase origin/main
It keeps your branch's commits on top, the history stays clean, and when you eventually merge, it's straightforward.
For the actual merge into main, I think squash-merge is usually the right call for most teams.
# On GitHub, just hit "Squash and merge" on the PR
# Or manually:
git checkout main
git merge --squash feature/invoice-export
git commit -m "Add invoice export to PDF and CSV formats"
Your main log becomes one commit per PR, which maps nicely to "one logical change." The detailed commit history still exists on the branch if you ever need to look at it.
But โ and I want to be honest here โ squash merging does lose information. If someone made ten careful, well-structured commits on a feature branch, squashing them into one commit throws away that narrative. For complex changes, that history can be useful when trying to understand why things were done a certain way six months later.
I don't have a clean answer. For most day-to-day work, squash-merge is fine. For large, carefully structured branches, maybe a regular merge is better. Use judgment. That's unsatisfying advice but I think it's correct.
Commit messages actually matter
Not going to write a whole essay about this but: a good commit message saves future-you real time. "fix bug" tells you nothing. "Fix off-by-one error in pagination that caused last page to show duplicate items" tells you exactly what happened and why.
The conventional commits format (feat:, fix:, chore:, etc.) is fine if your team likes it. I use it on some projects, not on others. The format matters less than the content. Just write a real description of what you did and why.
Automate the boring stuff
Pre-commit hooks, CI checks, automated formatting. Anything that a human will eventually forget to do manually.
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.css": ["prettier --write"]
}
}
I see teams that rely entirely on "please remember to run the linter before pushing." It works until it doesn't, which is usually during a crunch when people are rushing. Automation removes the question entirely.
Release management and how it intersects with branching
One thing that often gets lost in the branching strategy debate: how do you handle releases? If you deploy continuously from main, you might not think about releases at all. The latest commit on main is the release.
But if you need to track what went out, or if you have a customer asking "which version am I on?", you need some way to mark points in time. Git tags work well for this.
git tag -a v2.4.1 -m "Release 2.4.1 - invoice export and payment retry"
git push origin v2.4.1
Tags are lightweight, they don't require extra branches, and they give you a permanent marker you can reference. I use them even on projects that deploy continuously because being able to say "this bug was introduced between v2.4.0 and v2.4.1" is incredibly useful when debugging.
Some teams use release branches even within a GitHub Flow-style workflow. They cut a branch from main when they want to freeze what's going to production, do final testing on that branch, and then tag and deploy from it. New work continues on main in the meantime. This is a reasonable middle ground. It's not pure GitHub Flow and it's not GitFlow โ it's something practical in between.
I think being pragmatic about mixing approaches is fine, by the way. The blog posts about branching models describe idealized workflows. Real teams adapt them. That's normal and healthy.
What about monorepos?
Quick aside because this comes up a lot. If you have a monorepo with multiple services or packages, branching strategy gets more complicated because a single branch might touch code owned by different teams.
Trunk-based development tends to work better in monorepos, from what I have seen. The alternative โ long-lived feature branches in a monorepo โ leads to truly horrific merge conflicts because so many people are changing so many things in the same tree.
But TBD in a monorepo also requires good tooling. You need a CI system that can figure out what changed and only run the relevant tests. You need code ownership rules so the right people review the right parts. Nx, Turborepo, Bazel โ these tools exist for a reason.
I have less experience here than with single-app repos, so I will stop short of strong recommendations. Just flagging that monorepo dynamics change the calculus on branching in ways that are worth thinking about.
What I actually use
On the projects I work on currently, we use something that is basically GitHub Flow with short-lived branches and squash merges. Branches live one to three days max. CI runs on every push to a PR. Merging requires passing checks and at least one approval. We squash-merge almost everything. We tag releases.
We don't use develop. We don't use release branches. We do use feature flags for bigger changes that ship incrementally.
Is this the best approach? For our team size, our deployment model, and our risk tolerance โ I think so. For a team shipping a mobile SDK to thousands of developers who pin to specific versions? Probably not. For a two-person startup moving as fast as possible? They could probably get away with even less process.
The point is not to find the universally correct branching strategy. It is to find one that fits how your team works, agree on it, write it down somewhere, and then stop arguing about it. Revisit it if it starts causing real pain. But don't change it because someone read a new blog post about how Trunk-Based Development is the only true way.
I have changed my mind on branching approaches three or four times over the past decade. I will probably change it again. The tools evolve, the team size changes, the deployment story changes. What felt right for a team of five might feel completely wrong for a team of thirty.
So yeah. Pick something reasonable, protect main, keep branches short, automate what you can, and spend your meeting time on things that actually affect your users. The branching model is plumbing. Important plumbing, but still plumbing.
Written by
Anurag Sinha
Developer who writes about the stuff I actually use day-to-day. If I got something wrong, let me know.
Found this useful?
Share it with someone who might find it helpful too.
Comments
Loading comments...
Related Articles
Post-Mortem: A Disastrous Git Merge and the Resulting Workflow
A detailed breakdown of a force-push incident that deleted two days of work, and the strict git branching policies established in the aftermath.
Monolith vs. Microservices: How We Made the Decision
Our team's actual decision-making process for whether to break up a Rails monolith. Spoiler: we didn't go full microservices.
An Interview with an Exhausted Redis Node
I sat down with our caching server to talk about cache stampedes, missing TTLs, and the things backend developers keep getting wrong.