GitFlow Hotfix — Bug Reappears After Skipping Develop Merge
A hotfix to main but not develop reintroduced the same bug in v1.1.0; 6 hours wasted.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- main: production-ready, tagged releases only — receives merges from release or hotfix
- develop: integration branch — feature branches merge here when complete
- feature/*: cut from develop, merge back to develop — isolated feature work
- release/*: cut from develop, merge to main AND develop — stabilisation window
- hotfix/*: cut from main, merge to main AND develop — emergency production fixes
Imagine a busy restaurant kitchen. There's a prep area where chefs experiment with new dishes, a staging area where meals are plated and checked before leaving the kitchen, and the pass where only perfect plates go out to customers. GitFlow is exactly that system — but for your codebase. Every new feature gets its own prep space, nothing reaches production until it's been checked in staging, and if something goes wrong with a dish already at the table, there's a dedicated rescue process that doesn't halt the whole kitchen.
GitFlow is a branching model with five branch types that enforce separation between development, stabilisation, and production. Each branch type has a strict source and destination — feature branches from develop, release branches to main and develop, hotfix branches from main to both main and develop.
The model solves a specific problem: teams with scheduled, versioned releases that need a stabilisation window between development and production. It is not suited for teams deploying multiple times per day — trunk-based development with feature flags is better for that cadence.
Common misconceptions: that GitFlow is the only correct branching strategy (it is one option among several), that the git-flow CLI is required (it automates plain Git commands), and that hotfix branches only need to merge to main (they must also merge to develop or the bug reappears in the next release).
What Git Tags and Releases Actually Do
Git tags are immutable pointers to specific commits, typically used to mark release points (v1.0, v2.3.1). Unlike branches, tags do not move when new commits are added — they freeze a snapshot in time. Releases are the packaged artifacts (e.g., JARs, Docker images) associated with a tag, often built and deployed by CI/CD pipelines.
A tag is created with git tag -a v1.0 -m "Release 1.0" and pushed with git push origin v1.0. Once pushed, it should never be deleted or moved — rewriting history on a public tag breaks reproducibility and confuses downstream consumers. Tags are cheap (just a pointer), but their semantics are strict: they represent a contract that this exact code was shipped.
Use tags to mark every deployment to production, staging, or any long-lived environment. In a GitFlow setup, hotfix tags are critical: they must be applied to both main and develop. Skipping the develop merge means the fix is lost on the next release, causing the bug to reappear. Tags make this audit trail explicit — without them, you cannot prove which code was deployed when.
The Five-Branch Architecture — Why Each Branch Exists
GitFlow uses five branch types, and understanding the purpose of each is more important than memorising their names. Two branches are permanent and never get deleted: main and develop. Three are temporary and get cleaned up after use: feature/, release/, and hotfix/*.
main represents what's in production right now. Every commit on main is a tagged, deployed release. Nothing ever gets committed directly to main — it only ever receives merges from release or hotfix branches. Think of it as the restaurant's dining room: only finished, approved dishes arrive here.
develop is the integration branch — the staging kitchen. It holds the latest completed work that's been approved for the next release. Feature branches are cut from develop and merge back into develop when done. It's always ahead of main, and it might be slightly unstable on any given day, but it's never a mess.
Temporary branches are where the actual work happens. A feature/user-authentication branch gives one team or developer complete isolation. A release/2.4.0 branch freezes the feature set and allows only bug fixes before shipping. A hotfix/fix-payment-gateway-null-crash branch lets you patch production without touching any in-progress work. Every branch type has a strict source and a strict destination — that structure is what prevents the chaos described in the introduction.
- Fast-forward merges linearise commits — feature boundaries disappear from git log --graph
- --no-ff forces a merge commit that preserves the feature's visual history
- Without --no-ff, git bisect cannot isolate which feature introduced a regression
- The git-flow CLI handles this automatically. With raw Git, add a global alias: git config --global alias.mff 'merge --no-ff'
Handling Emergency Production Bugs With Hotfix Branches
Here's the scenario no one wants but every team eventually faces: it's Thursday afternoon, version payment processing bug is crashing checkouts for 15% of users. Meanwhile, develop already has three half-finished features merged into it that absolutely cannot ship with this emergency fix.
This is exactly why hotfix branches exist — and why their source branch being main (not develop) is so deliberate. By branching from main, you get a clean copy of exactly what's in production, untainted by anything on develop. You fix only the bug, merge back to main, tag a new patch version, and then — critically — also merge back to develop so the fix isn't lost.
That last step trips people up constantly. If you only merge the hotfix into main and forget develop, the bug you just fixed will silently re-appear in your next scheduled release when develop gets merged to main. It's one of the most insidious mistakes in GitFlow practice.
The hotfix branch also signals urgency to your team through its name. When someone sees hotfix/fix-payment-null-pointer, everyone on the team immediately knows this isn't routine work — it's a production incident, and it has priority.
- Hotfixes bump PATCH: 1.0.0 → 1.0.1 (bug fix, no new functionality)
- Feature releases bump MINOR: 1.0.0 → 1.1.0 (new functionality, backward compatible)
- Breaking changes bump MAJOR: 1.0.0 → 2.0.0 (backward incompatible changes)
- Anyone reading git tag instantly knows the risk level of each release
GitFlow vs Trunk-Based Development — Choosing the Right Workflow
GitFlow is a powerful model, but it isn't the right model for every team or every product — and knowing when not to use it is as important as knowing how to use it.
GitFlow shines when your team ships scheduled, versioned releases — think desktop software, mobile apps, open-source libraries, or enterprise SaaS with quarterly release windows. The structured branch lifecycle gives you clear separation between what's done, what's being stabilised, and what's in progress. QA teams love it because there's an explicit release branch to test against. Ops teams love it because main is always a known-good, tagged state.
Trunk-based development, by contrast, is better for teams deploying multiple times a day. Everyone commits to a single main branch (or very short-lived feature branches). Feature flags control what's visible in production. The overhead of maintaining five branch types would actively slow these teams down.
The honest answer: if your CI/CD pipeline deploys to production on every merge and your team has mature feature flagging, trunk-based is likely faster for you. If you have a QA cycle, multiple environments, compliance requirements, or a public API with versioned releases, GitFlow's structure pays for itself many times over.
- GitFlow adds overhead that pays off for scheduled, versioned releases
- Trunk-based development is faster for teams deploying multiple times per day
- Feature flags replace release branches for continuous deployment teams
- The right workflow matches your deployment cadence, not the other way around
Why You Need to Stop Treating Tags Like Branches
I've lost count of the number of post-mortems I've sat through where the root cause was someone force-pushing a tag. Tags are not branches. They are immutable pointers to a specific commit. Once you move a tag, you've just rewritten history for everyone who depends on that release marker. Git doesn't protect you from yourself here — it will let you overwrite an annotated tag with git push --force --tags and the remote will silently accept it. That's how you get a production deployment claiming to be v2.3.1 that actually contains hotfix code from v2.3.2. The attacker? Decent intentions. The defender? A CI/CD pipeline that doesn't check tag immutability.
Here's the rule: treat annotated tags as signed contracts. If you need to mark a release, create an annotated tag with a message that includes the commit hash and the build number. Never, ever force-push tags. If you mess up, delete the tag locally and remotely (git tag -d v2.3.1 && git push origin :refs/tags/v2.3.1), then create a new tag with a different name. Your CI/CD pipeline should reject any tag that already exists in the remote. This isn't paranoia — it's standard for any regulated environment.
git tag -a v2.3.1 HEAD && git push --force --tags, you've just moved the release marker without anyone noticing. Add a server-side hook or CI validation to reject tag re-pushes.Viewing and Filtering Tags When You Have 400 of Them
The competitor docs show you how to click through a UI to view tags. That's fine if you have three tags and eight commits. But when your repo has 400 tags accumulated over four years of bi-weekly releases, the UI is useless. You need to filter in the terminal, and you need to filter fast.
The core command is git tag -l with a pattern. Most teams use semantic versioning, so git tag -l "v2.." will show you all v2 releases. Need to see only release candidates? git tag -l "vrc" will pull every RC tag. Pair it with --sort=-creatordate to see the most recent first: git tag -l "v2.." --sort=-creatordate | head -10. That's how you find the last stable release to cherry-pick into a hotfix.
Deleting tags in bulk is another skill. Never do this manually. The command git push origin --delete $(git tag -l "v2.0.0-*" | grep -E 'rc|beta') will wipe all RC and beta tags for the v2.0.0 series from the remote — but only if you're absolutely certain. Test it locally first by running the grep part alone. And yes, you should do this before a major release to clean up old garbage.
git tag -l "v..*" --sort=-creatordate | head -5 into a CI job step to print the last five releases in your build log. It's a cheap way to surface the exact commit you need for a hotfix without asking a human.git tag -l with patterns and sorting. Never manually delete tags — script it.Hotfix Not Back-Ported to Develop: Bug Reappears in Next Release
git checkout -b hotfix/fix-payment-null main.
3. The fix was applied and merged to main with tag v1.0.1.
4. The developer forgot to merge the hotfix branch back to develop.
5. Three weeks later, develop was merged to main for the v1.1.0 release.
6. Since develop never received the hotfix, the null pointer code was reintroduced.
7. v1.1.0 shipped with the same bug that was fixed in v1.0.1.
8. The team spent 6 hours bisecting before identifying the root cause.git cherry-pick <hotfix-sha>.
3. Re-tag v1.1.0 with the fix included: git tag -f -a v1.1.0.
4. Team rule: hotfix finish must always merge to both main AND develop. Added a CI check that verifies hotfix commits appear in both branches.
5. Added a post-merge hook that warns when a hotfix branch is deleted without a corresponding merge to develop.- Hotfix branches must merge to BOTH main AND develop. Skipping develop causes the bug to reappear in the next release.
- main and develop are separate branches. A merge to main does not affect develop.
- Add a CI check that verifies hotfix commits appear in both main and develop after the hotfix branch is deleted.
- When debugging a regression, check if the fix was ever merged to develop — do not assume it was.
git log develop --oneline | grep <hotfix-sha-prefix>.
2. If missing: cherry-pick the hotfix commit to develop: git cherry-pick <hotfix-sha>.
3. If the release already shipped: revert the deployment, back-port the fix, re-tag.
4. Prevention: add CI check that verifies hotfix commits appear in both main and develop.git rebase develop from the feature branch.
3. Resolve conflicts incrementally during the rebase (one commit at a time).
4. If too messy: consider squashing the feature branch first, then rebasing the single commit.
5. Prevention: keep feature branches short-lived (max 1-2 weeks). Use feature flags for long-running work.git log release/1.1.0 --oneline — look for feat: commits.
2. Revert them on the release branch: git revert <commit-sha> for each.
3. If the features are needed: they wait for the next release cycle. Do not cherry-pick them back.
4. Prevention: enforce PR labels. Release branches only accept fix:, chore:, docs: prefixes.git log develop --oneline -10 — find the most recent merge.
2. Revert the merge: git revert -m 1 <merge-commit-sha> (reverts the merge on develop).
3. Notify the feature branch owner to fix their branch before re-merging.
4. Prevention: require CI to pass on the feature branch before merging to develop.git log release/1.1.0 --oneline vs git log release/1.2.0 --oneline.
3. If release/1.1.0 is the priority: finish it first (merge to main + develop), then cut release/1.2.0.
4. If release/1.2.0 supersedes 1.1.0: delete 1.1.0 and proceed with 1.2.0.
5. Prevention: only one release branch at a time. Finish or delete before cutting a new one.git log develop --oneline | grep <hotfix-sha-prefix>git cherry-pick <hotfix-sha> (back-port to develop)Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Git. Mark it forged?
6 min read · try the examples if you haven't