Say a team has an automated pipeline: tests run on every commit, builds are automatic. But the developers work in branches that live for several weeks. And when the time comes to merge, disaster strikes: hours of conflicts, bugs at the seam between two big changes that nobody tested together. No pipeline will save you from that. The problem isn't the tooling — it's how branches and the release cycle are organized.
Why long branches break integration
Merge conflicts are a symptom. The real problem is that while a branch lives on its own, it drifts away from what's in the main branch. That drift grows with every day. After two weeks, two branches have diverged so much that even an automatic merge guarantees nothing: the code merged technically, but not logically. Bugs show up in production.
There's an important pattern here: integration complexity grows quadratically with the branch's lifetime. A one-day branch is a trivial merge. A one-month branch is a month of drift and half a day of conflicts.
Continuous integration in its original sense means exactly this: developers integrate their changes into the shared branch often, every day. Not "the server runs tests," but frequent integration of the code itself.
Trunk-based development
This is an approach where there's a single main branch (usually main), and all changes land in it quickly — through small, short-lived branches. "Short-lived" means a day or two, at most a few days, not weeks.
Big tasks don't disappear in this model. They're cut into a sequence of small steps, each of which can be safely merged into main. If a feature isn't ready to show to users yet, the code still lands in main, but behind a turned-off flag — more on that below.
The old GitFlow — a develop branch, release branches, hotfix branches — made sense for shrink-wrapped software released a few times a year. For a service with continuous delivery it adds overhead without real protection. The exception is supporting several versions at once: when you need to patch version 1.x and 2.x in parallel, long-lived branches are justified. But that's not the typical case for an ordinary web service.
How a PR works in practice
Trunk-based isn't just "shorter branches." It rests on several team norms.
A small PR is one logical unit. That can be one vertical slice of a feature, one fix, one refactoring step. Nobody truly reviews a thousand-line PR — they skim it and approve. The bugs stay.
Review within the day. If a PR sits for a week, the author is blocked, conflicts pile up in their branch, and motivation drops. In trunk-based teams, reviewing someone else's code ranks higher than writing your own new code.
A green pipeline before review. The reviewer looks at design and meaning rather than catching syntax errors. Let the machine catch what the machine can catch.
Squash on merge. The history in main is a stream of meaningful changes, one entry per PR. Not "fix," "fix2," "let me try another way" — but a single entry with a clear description of what was done.
Feature flags: how to hide the unfinished
Say a new feature can't be shown to users until it's fully ready. The classic answer is to keep the branch open until it's done. That's exactly what leads to multi-week branches.
The alternative is a feature flag: the code goes into main and gets deployed, but the feature is turned off. When product decides "okay, it's ready," the flag is flipped on and the feature becomes visible. This separates deploying code from releasing a feature: deploys happen often and safely, while turning a feature on is a separate decision.
Flags range from simple boolean variables in configuration to full-fledged systems like Unleash, which let you enable a feature for individual users, a percentage of the audience, or specific groups.
How to assign versions
For a service that deploys continuously, the version is the commit SHA. Every commit in main is a potential release. The SHA unambiguously identifies exactly what's running in production right now. It's visible in /actuator/info and in the logs. Human-friendly numbers like "1.4.2" aren't needed here.
SemVer (major.minor.patch) belongs where there are external consumers of a contract: libraries, public APIs, versioned events. The numbers convey the meaning of a change:
- patch — a fix without changing the contract;
- minor — new functionality, backward compatibility preserved;
- major — a breaking change, consumers must adapt.
Assigning these numbers by hand is a bad idea: people make mistakes and get confused. SemVer is derived automatically from conventional commits — a standard format for commit messages:
feat: add a search-by-tags method
fix: fix result sorting
feat!: change the API response format (breaking change)
From such a log, tools work out which number to bump and generate a changelog on their own.
GitOps: what's in git is what's in production
Traditional deploy: someone runs a script, posts "deploying" in chat, presses a button. What's in production right now can only be learned by asking whoever deployed last. Rollback is a manual operation.
GitOps flips this around: the desired state of production is stored in a git repository. A tool (usually Argo CD) watches that repository and makes sure the cluster holds exactly what's described there. No manual deploys — only changes in git.
Practical consequences:
- deploy history = git log — it's clear what changed, when, and by whom;
- rollback = revert — an ordinary git operation, reproducible and understandable;
- auditing is free — everything is documented in commits.
The loop from commit to production
Here's what the full cycle looks like in a team with trunk-based + GitOps:
PR → pipeline (tests, linter, build) → merge into main
→ image tagged with SHA → automatic deploy to staging
→ verification → PR with a new tag into the GitOps repository
→ Argo CD updates production
→ flip the flag when the feature is ready for users
There are two human decisions in this loop: approve the PR and flip the flag. Everything else is automated.
Common mistakes
| Problem | What happens | How to fix |
|---|---|---|
| A feature branch lives for a month | Conflict-ridden merge, untested seams | Cut into small PRs + feature flags |
| Develop + release + hotfix for an ordinary service | Excess ceremony, unclear where the truth is | Trunk-based: main is the single source of truth |
| A PR sits for a week | Blocking, conflicts, demotivation | Review within the day as a team norm |
| Version "1.0-final-new2" by hand | Unclear what's in production | SHA for services, auto-SemVer for libraries |
| Deploy "by agreement in chat" | Not repeatable, not traceable | GitOps: a PR into the config repository is the only path |
| A huge "the whole feature at once" PR | Diagonal review, bugs in production | Vertical slices; flags for what's not ready |
In short
- Long branches create conflicts and untested seams — complexity grows quadratically with the branch's lifetime.
- Trunk-based development: a single main branch, changes land in it quickly through small short-lived branches.
- Unfinished code hides behind feature flags — this separates deploying code from turning a feature on.
- For services — SHA as the version; for libraries and public APIs — SemVer, derived automatically from conventional commits.
- GitOps: the desired state of production is described in git, and Argo CD keeps things in sync. Rollback = revert, deploy history = git log.
- Squash on merge keeps the
mainhistory readable.
What to read next
- Pipeline principles — a green main and deploy ≠ release.
- CI: build, tests, quality gates — the gates every PR passes through.
- Release strategies — rolling, blue-green, canary, and feature flags in detail.
- Deploying to Kubernetes — how GitOps works at the cluster level.