Intermediate 6 min · March 06, 2026
Git Tags and Releases

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.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Git Tags and Releases?

Git tags are immutable pointers to specific commits, designed for marking release points (v1.2.3) or significant milestones. Unlike branches, tags don't move when new commits are added — they're permanent labels. Releases are the packaged artifacts (binaries, tarballs, Docker images) associated with those tags, typically published to registries like GitHub Releases, GitLab Releases, or artifact repositories.

Imagine a busy restaurant kitchen.

The core distinction: tags are metadata in your Git history; releases are the deployable units your ops team actually pushes to production. When you skip merging a hotfix back into develop, you're not just breaking workflow convention — you're creating a time bomb where the next release will reintroduce the bug because the fix never reached the integration branch.

This is why GitFlow mandates the hotfix→develop merge: without it, your release tags become unreliable snapshots of what's actually fixed.

Plain-English First

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.

Tag Immutability Is Not Enforced by Git
Git allows force-pushing a tag to a different commit — but doing so silently breaks every build, deployment, and rollback that references that tag.
Production Insight
Team hotfixes a production bug on main, tags v1.1, but forgets to merge to develop. Next release from develop overwrites the fix — bug reappears in production.
Symptom: same stack trace, same line number, same root cause — but no one notices until the next deployment because the tag v1.1 still points to the correct commit.
Rule: Every hotfix tag must have a corresponding merge commit into develop before the tag is pushed. Automate this check in CI.
Key Takeaway
Tags are immutable contracts — never rewrite a public tag.
A release without a tag is not a release; a tag without a release artifact is just a marker.
In GitFlow, a hotfix that skips develop is a time bomb — the bug will return on the next release.
GitFlow Hotfix: Bug Reappears After Skipping Develop Merge THECODEFORGE.IO GitFlow Hotfix: Bug Reappears After Skipping Develop Merge Why hotfix branches must merge to both master and develop Hotfix Branch Created from Master Emergency fix branched off master tag Fix Merged Only to Master Hotfix merged and tagged on master only Develop Lacks the Fix Bug fix not merged into develop branch Next Release from Develop Release branch created from develop Bug Reappears in Release Missing hotfix causes regression Hotfix Merged to Both Branches Always merge hotfix to master and develop ⚠ Skipping develop merge reintroduces the bug Always merge hotfix to develop to prevent regression THECODEFORGE.IO
thecodeforge.io
GitFlow Hotfix: Bug Reappears After Skipping Develop Merge
Git Tags Releases

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.

io/thecodeforge/gitflow/ProjectSetup.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# io.thecodeforge — GitFlow Project Setup

# ─────────────────────────────────────────────────────────────
# Full GitFlow project setup from scratch — no plugins required.
# This uses plain Git commands so you understand what's happening
# under the hood before you reach for the git-flow CLI extension.
# ─────────────────────────────────────────────────────────────

# Step 1: Create a new repo and establish the permanent branches.
git init ecommerce-platform
cd ecommerce-platform

# Create an initial commit so 'main' has a history to branch from.
echo "# E-Commerce Platform" > README.md
git add README.md
git commit -m "chore: initial project setup"

# Rename the default branch to 'main' if your Git version defaults to 'master'.
git branch -M main

# Step 2: Create the 'develop' branch from 'main'.
# 'develop' is the long-lived integration branch — all feature work lands here.
git checkout -b develop

# At this point we have our two permanent branches.
git branch
# * develop
#   main

# Step 3: Start a new feature branch.
# Feature branches are ALWAYS cut from 'develop', never from 'main'.
# The 'feature/' prefix is a convention, not a Git requirement — but follow it.
git checkout -b feature/user-authentication develop

# Simulate some development work on the feature.
echo "def authenticate_user(email, password): pass" > auth.py
git add auth.py
git commit -m "feat(auth): add user authentication scaffold"

echo "def validate_token(token): pass" >> auth.py
git add auth.py
git commit -m "feat(auth): add JWT token validation"

# Step 4: Merge the completed feature back into 'develop'.
# --no-ff (no fast-forward) forces a merge commit, which preserves the
# history of the feature branch in the graph. Without this, the feature's
# commits get absorbed into develop's linear history and you lose visibility.
git checkout develop
git merge --no-ff feature/user-authentication -m "merge: integrate user authentication feature into develop"

# Clean up the local feature branch — it's served its purpose.
git branch -d feature/user-authentication

# Step 5: Cut a release branch when develop is ready to ship.
# Release branches are cut from 'develop'. Only bug fixes, docs updates,
# and release-preparation commits should land here. No new features.
git checkout -b release/1.0.0 develop

# Simulate a pre-release bug fix found during QA.
echo "# auth module v1.0.0" >> auth.py
git add auth.py
git commit -m "fix(auth): handle empty password edge case before release"

# Step 6: Finalise the release — merge into BOTH main AND develop.
# main gets the release so production is updated.
git checkout main
git merge --no-ff release/1.0.0 -m "release: ship v1.0.0 to production"
git tag -a v1.0.0 -m "Version 1.0.0 — initial public release"

# develop must also receive the release branch changes (e.g., the QA bug fix)
# so that the fix isn't lost in future work.
git checkout develop
git merge --no-ff release/1.0.0 -m "merge: back-port release/1.0.0 fixes into develop"

# Clean up the release branch.
git branch -d release/1.0.0

echo "GitFlow project structure established successfully."
Output
Initialized empty Git repository in /projects/ecommerce-platform/.git/
[main (root-commit) 4a1b2c3] chore: initial project setup
1 file changed, 1 insertion(+)
Switched to a new branch 'develop'
* develop
main
Switched to a new branch 'feature/user-authentication'
[feature/user-authentication 7d3e4f5] feat(auth): add user authentication scaffold
[feature/user-authentication 9a1b0c2] feat(auth): add JWT token validation
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch feature/user-authentication (was 9a1b0c2).
Switched to a new branch 'release/1.0.0'
[release/1.0.0 2f8d1e9] fix(auth): handle empty password edge case before release
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.0.0 (was 2f8d1e9).
GitFlow project structure established successfully.
Watch Out: Always Use --no-ff When Merging Feature Branches
  • 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'
Production Insight
The five-branch architecture exists to enforce separation of concerns at the workflow level. main is production — nothing untested reaches it. develop is integration — features merge here for testing. feature branches provide isolation — developers work without stepping on each other. release branches provide stabilisation — QA has a frozen target. hotfix branches provide urgency — production fixes do not drag in unfinished work. Each branch type answers one question: 'where does this change go, and what is allowed on it?'
Key Takeaway
Five branch types, each with a strict source and destination. main = production (tagged). develop = integration. feature = isolation. release = stabilisation. hotfix = emergency. Always use --no-ff on merges to preserve branch boundaries.

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.

io/thecodeforge/gitflow/HotfixCycle.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# io.thecodeforge — GitFlow Hotfix Cycle

# ─────────────────────────────────────────────────────────────
# GitFlow Hotfix CycleEmergency production bug repair.
# Scenario: v1.0.0 is live. A null pointer crash is breaking
# the payment gateway for a subset of users. develop has
# unfinished work that cannot ship. We must fix prod NOW.
# ─────────────────────────────────────────────────────────────

# Step 1: Branch from 'main'NOT from 'develop'.
# main = what's in production. develop = what's coming next.
# We want a clean snapshot of prod to work from.
git checkout main
git checkout -b hotfix/fix-payment-gateway-null-crash

# Step 2: Apply the targeted fix.
# In a real scenario this is where your developer fixes the bug.
cat > payment_gateway.py << 'EOF'
def process_payment(order_id, payment_details):
    # HOTFIX v1.0.1: Added None guard — payment_details was not
    # validated upstream, causing a NullPointerError on orders
    # created via the mobile API which omits the billing_zip field.
    if payment_details is None:
        raise ValueError("payment_details cannot be None — check mobile API payload")
    
    billing_zip = payment_details.get("billing_zip", "00000")  # safe default
    return {"status": "approved", "order_id": order_id, "zip": billing_zip}
EOF

git add payment_gateway.py
git commit -m "fix(payments): guard against None payment_details from mobile API"

# Step 3: Update the version number in the project metadata.
# This is important — production tags must be unique and meaningful.
echo "1.0.1" > VERSION
git add VERSION
git commit -m "chore(release): bump version to 1.0.1 for hot 1.0.0 is live, and a criticalfix"

# Step 4: Merge hotfix into 'main' and tag the new production version.
git checkout main
git merge --no-ff hotfix/fix-payment-gateway-null-crash \
  -m "hotfix: merge payment gateway null crash fix into main"
git tag -a v1.0.1 -m "Version 1.0.1 — Emergency fix for payment gateway null crash"

# Step 5: CRITICAL — merge hotfix into 'develop' too.
# If you skip this, the bug will resurface in v1.1.0 when develop
# gets merged to main. This step is the one most teams forget.
git checkout develop
git merge --no-ff hotfix/fix-payment-gateway-null-crash \
  -m "hotfix: back-port payment gateway null crash fix into develop"

# Step 6: Delete the hotfix branch — it has served its purpose.
git branch -d hotfix/fix-payment-gateway-null-crash

# Verify the tag exists on main
git checkout main
git log --oneline --graph -5

echo "Hotfix v1.0.1 shipped and back-ported to develop successfully."
Output
Switched to branch 'main'
Switched to a new branch 'hotfix the git-flow CLI which handles the/fix-payment-gateway-null-crash'
[hotfix/fix-payment-gateway-null-crash 3c7f2a1] fix(payments): guard against None payment_details from mobile API
[hotfix/fix-payment-gateway-null-crash 8b4e9d0] chore(release): bump version to 1.0.1 for hotfix
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch hotfix/fix-payment-gateway-null-crash (was 8b4e9d0).
Switched to branch 'main'
* 5e1a3b2 (HEAD -> main, tag: v1.0.1) hotfix: merge payment gateway null crash fix into main
|\
| * 8b4e9d0 chore(release): bump version to 1.0.1 for hotfix
| * 3c7f2a1 fix(payments): guard against None payment_details from mobile API
|/
* 4d9c8f1 (tag: v1.0.0) release: ship v1.0.0 to production
Hotfix v1.0.1 shipped and back-ported to develop successfully.
Pro Tip: Use Semantic Versioning Tags Consistently
  • 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
Production Insight
The hotfix double-merge (main + develop) is the most commonly forgotten step in GitFlow. The fix merges to main and ships. But develop never receives it. Three weeks later, when the next release is cut from develop, the bug reappears. The team spends hours bisecting before realising the hotfix was never back-ported. The fix: add a CI check that verifies hotfix commits appear in both main and develop after the hotfix branch is deleted. Or use double-merge automatically.
Key Takeaway
Hotfix branches must branch from main and merge to BOTH main AND develop. Skipping develop causes the bug to reappear in the next release. This is the single most common GitFlow mistake. Add a CI check to prevent it.

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.

io/thecodeforge/gitflow/CliQuickstart.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# io.thecodeforge — GitFlow CLI Quickstart

# ─────────────────────────────────────────────────────────────
# Using the git-flow CLI extension — the faster way once you
# understand the underlying model. Install first:
#   macOS:  brew install git-flow-avh
#   Ubuntu: apt-get install git-flow
#   Windows: included in Git for Windows
# ─────────────────────────────────────────────────────────────

# Initialise GitFlow in an existing repo.
# This sets up the branch naming conventions in .git/config.
# Accept all the defaults by pressing Enter through the prompts,
# or use -d flag for fully non-interactive default setup.
git flow init -d

# ── FEATURE WORKFLOW ──────────────────────────────────────────
# Start a feature — automatically branches from 'develop'
git flow feature start shopping-cart-persistence

# ... do your work, make commits ...
echo "def save_cart(user_id, cart_items): pass" > cart.py
git add cart.py
git commit -m "feat(cart): implement session-based cart persistence"

# Finish the feature — merges into develop with --no-ff, deletes branch
git flow feature finish shopping-cart-persistence
# Output: Switched to branch 'develop'
#         Merge made by the 'ort' strategy.
#         Deleted branch feature/shopping-cart-persistence

# ── RELEASE WORKFLOW ──────────────────────────────────────────
# Start a release branch from develop's current state
git flow release start 1.1.0

# Apply only bug fixes and release prep commits here
echo "1.1.0" > VERSION
git add VERSION
git commit -m "chore(release): bump version to 1.1.0"

# Finish the release:
# - merges into main AND develop
# - creates a tag automatically
# - deletes the release branch
# -m sets the tag message without opening an editor
git flow release finish -m "Version 1.1.0 — Shopping cart persistence" 1.1.0

# ── HOTFIX WORKFLOW ───────────────────────────────────────────
# Start a hotfix from main (production)
git flow hotfix start fix-cart-total-rounding-error

# Fix the bug
echo "def calculate_total(items): return round(sum(i.price for i in items), 2)" >> cart.py
git add cart.py
git commit -m "fix(cart): correct floating-point rounding in total calculation"

# Finish hotfix — merges to main AND develop, tags, deletes branch
git flow hotfix finish -m "Hotfix: cart total rounding" fix-cart-total-rounding-error

# Verify your tag history
git tag --list
echo "All workflows complete."
Output
Using default branch names.
Summary of actions:
- A new branch 'develop' was created
- You are now on branch 'develop'
Switched to a new branch 'feature/shopping-cart-persistence'
[feature/shopping-cart-persistence 1a2b3c4] feat(cart): implement session-based cart persistence
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch feature/shopping-cart-persistence (was 1a2b3c4).
Switched to a new branch 'release/1.1.0'
[release/1.1.0 5d6e7f8] chore(release): bump version to 1.1.0
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.1.0 (was 5d6e7f8).
Switched to a new branch 'hotfix/fix-cart-total-rounding-error'
[hotfix/fix-cart-total-rounding-error 9g0h1i2] fix(cart): correct floating-point rounding in total calculation
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch hotfix/fix-cart-total-rounding-error (was 9g0h1i2).
v1.0.0
v1.0.1
v1.1.0
All workflows complete.
Interview Gold: Know When to Argue Against GitFlow
  • 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
Production Insight
The decision between GitFlow and trunk-based development is not about preference — it is about deployment cadence. If you ship on a schedule (weekly, monthly, quarterly) with QA gates, GitFlow's structure prevents half-finished work from reaching production. If you ship on every merge (continuous deployment), GitFlow's branch overhead slows you down without adding value. The test: how often do you deploy? More than once a day: trunk-based. Less than once a week: GitFlow. In between: evaluate your QA and compliance requirements.
Key Takeaway
GitFlow is for scheduled, versioned releases with QA gates. Trunk-based development is for continuous deployment with feature flags. The right choice matches your deployment cadence. Know the trade-offs — interviewers test this.

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.

TagGuardPipeline.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — devops tutorial

// Fail the pipeline if tag already exists on remote
name: Tag Release Validation
on:
  push:
    tags:
      - 'v*'

jobs:
  validate-tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Check tag uniqueness
        run: |
          TAG_NAME="${GITHUB_REF#refs/tags/}"
          git ls-remote origin refs/tags/${TAG_NAME} | wc -l > tag_count.txt
          if [ $(cat tag_count.txt) -ne 0 ]; then
            echo "ERROR: Tag ${TAG_NAME} already exists on remote. Aborting."
            exit 1
          fi
      - name: Create release
        run: echo "Deploying release for tag ${GITHUB_REF#refs/tags/}"
Output
ERROR: Tag v2.3.1 already exists on remote. Aborting.
Production Trap: The Silent Tag Overwrite
GitLab and GitHub both allow force-pushing tags by default. If you're using trunk-based development and a junior runs 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.
Key Takeaway
Tags are immutable contracts. Never force-push a tag. Validate uniqueness in CI/CD.

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.

TagFilterCommands.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — devops tutorial

// View all v2 release tags, newest first
git tag -l "v2.*.*" --sort=-creatordate | head -10

// Output:
// v2.4.1
// v2.4.0
// v2.3.2
// v2.3.1
// v2.3.0
// v2.2.3
// v2.2.2
// v2.2.1
// v2.2.0
// v2.1.4

// Delete all RC tags for v2.0.0 series from remote
git push origin --delete $(git tag -l "v2.0.0-*" | grep -E 'rc|beta')
Output
To github.com:thecodeforge/app.git
- [deleted] refs/tags/v2.0.0-rc.1
- [deleted] refs/tags/v2.0.0-rc.2
- [deleted] refs/tags/v2.0.0-beta.1
Senior Shortcut: Tag Grep in CI/Git Hooks
Drop 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.
Key Takeaway
Use git tag -l with patterns and sorting. Never manually delete tags — script it.
● Production incidentPOST-MORTEMseverity: high

Hotfix Not Back-Ported to Develop: Bug Reappears in Next Release

Symptom
After deploying v1.1.0, the payment gateway started crashing with the same NullPointerError that was fixed in v1.0.1. The team assumed it was a regression introduced by a new feature. They spent 6 hours bisecting develop's history before realising the hotfix commit from v1.0.1 was never merged into develop.
Assumption
The developer who merged the hotfix to main assumed that the fix would automatically propagate to develop. They did not realise that main and develop are separate branches with separate histories. A merge to main does not affect develop unless you explicitly merge to develop as well.
Root cause
1. A null pointer crash was discovered in production (v1.0.0) in payment_gateway.py. 2. A hotfix branch was created from main: 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.
Fix
1. Immediate: revert the v1.1.0 deployment and deploy v1.0.1 as a rollback. 2. Cherry-pick the hotfix commit from v1.0.1 into develop: 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.
Key lesson
  • 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.
Production debug guideSystematic recovery paths for forgotten backports, merge conflicts, and branch strategy failures.5 entries
Symptom · 01
Bug fixed in hotfix reappeared in next release — hotfix was not back-ported to develop
Fix
1. Check if the hotfix commit is in develop: 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.
Symptom · 02
Feature branch has massive merge conflicts with develop — branch has diverged too far
Fix
1. The feature branch has been open too long. It has diverged significantly from develop. 2. Rebase the feature branch onto 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.
Symptom · 03
Release branch has new features that should not be there — scope creep
Fix
1. Identify the offending commits: 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.
Symptom · 04
Develop is broken after merging a feature — tests failing on the integration branch
Fix
1. Identify the breaking feature: 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.
Symptom · 05
Two release branches exist simultaneously — which one ships?
Fix
1. This happens when a release branch was not finalised before a new one was cut. 2. Check what each release branch contains: 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.
★ GitFlow Triage Cheat SheetFast recovery for GitFlow workflow failures, forgotten backports, and branch strategy issues.
Hotfix bug reappeared in next release — not back-ported to develop
Immediate action
Check if the hotfix commit exists in develop's history.
Commands
git log develop --oneline | grep <hotfix-sha-prefix>
git cherry-pick <hotfix-sha> (back-port to develop)
Fix now
Prevention: CI check that verifies hotfix commits appear in both main and develop.
Feature branch has massive merge conflicts with develop+
Immediate action
Rebase the feature branch onto develop to resolve incrementally.
Commands
git checkout feature/branch && git rebase develop
git log develop..feature/branch --oneline (see diverged commits)
Fix now
If too messy: squash first, then rebase the single commit. Prevention: keep features short-lived.
Release branch has new features that should not be there+
Immediate action
Revert the feature commits on the release branch.
Commands
git log release/x.x.x --oneline (find feat: commits)
git revert <commit-sha> (revert each offending commit)
Fix now
Features wait for next release. Prevention: enforce PR labels on release branches.
Develop is broken after feature merge — tests failing+
Immediate action
Revert the merge on develop.
Commands
git log develop --oneline -5 (find the breaking merge)
git revert -m 1 <merge-commit-sha> (revert the merge)
Fix now
Notify feature owner to fix before re-merging. Prevention: require CI pass before merge.
Two release branches exist simultaneously — which one ships?+
Immediate action
Compare what each release branch contains.
Commands
git log release/1.1.0 --oneline (see commits)
git log release/1.2.0 --oneline (see commits)
Fix now
Finish the priority release first, then cut the next. Prevention: one release branch at a time.
GitFlow vs Trunk-Based Development
AspectGitFlowTrunk-Based Development
Permanent branchesmain + developmain only
Feature isolationDedicated feature/* branchesShort-lived branches or direct commits
Release managementExplicit release/* branch with stabilisation windowFeature flags control release visibility
Hotfix processhotfix/* from main, merged to main + developCommit directly to main, deploy immediately
Best suited forScheduled releases, versioned APIs, mobile appsSaaS with continuous deployment, high-frequency releases
Branch complexityHigh — 5 branch types with strict rulesLow — one branch, maximum simplicity
Parallel version supportExcellent — maintain v1.x and v2.x simultaneouslyDifficult — requires additional branching strategy
CI/CD compatibilityWorks well with staged environments (dev/staging/prod)Optimal — every commit can be production-ready
Team size sweet spotMedium to large teams with defined rolesAny size, especially high-trust senior teams
Risk of merge conflictsHigher — long-lived feature branches diverge moreLower — frequent integration keeps branches short

Key takeaways

1
GitFlow's two permanent branches (main and develop) serve completely different purposes
main is always production-ready and tagged; develop is the integration point for completed features and is allowed to be slightly unstable day-to-day.
2
Hotfix branches must always branch from main and merge into BOTH main AND develop
skipping the develop merge is the single most common GitFlow mistake and causes previously fixed bugs to reappear in future releases.
3
The --no-ff flag on every GitFlow merge is not optional
it preserves the visual history of branch boundaries in your Git graph, which is critical for debugging regressions and understanding what changed in each release.
4
GitFlow is the right choice for scheduled, versioned releases; it's the wrong choice for teams deploying multiple times per day
matching your branching strategy to your deployment cadence is more important than following any single model dogmatically.
5
Keep feature branches short-lived (max 1-2 weeks). Long-lived branches diverge from develop and produce merge conflicts. Use feature flags for work that takes longer.
6
Only one release branch at a time. Cutting a new release before finishing the previous one creates confusion and duplicated bug fixes.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between GitFlow and GitHub Flow?
02
Do I need to use the git-flow CLI extension to use GitFlow?
03
Can you have multiple feature branches active at the same time in GitFlow?
04
What happens if I forget to merge a hotfix back to develop?
05
How do I decide between GitFlow and trunk-based development?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Git. Mark it forged?

6 min read · try the examples if you haven't

Previous
GitHub Pull Requests and Code Review
7 / 19 · Git
Next
Resolving Git Merge Conflicts