Home DevOps Git Branching and Merging Explained — Strategies, Conflicts and Real-World Workflows

Git Branching and Merging Explained — Strategies, Conflicts and Real-World Workflows

In Plain English 🔥
Imagine you're writing a novel and you want to try a completely different ending without ruining the original. You photocopy the last 50 pages, scribble all over the copy, and if you love the new ending, you glue it back into the original book. That photocopied stack of pages is a Git branch — a safe, isolated space to experiment. Merging is the gluing-back step. If both you and your editor rewrote the same paragraph differently, that's a merge conflict, and Git is asking you: 'Hey, whose version wins?'
⚡ Quick Answer
Imagine you're writing a novel and you want to try a completely different ending without ruining the original. You photocopy the last 50 pages, scribble all over the copy, and if you love the new ending, you glue it back into the original book. That photocopied stack of pages is a Git branch — a safe, isolated space to experiment. Merging is the gluing-back step. If both you and your editor rewrote the same paragraph differently, that's a merge conflict, and Git is asking you: 'Hey, whose version wins?'

Every professional software team on the planet uses Git branching. Not because it's a rule, but because shipping features without it is like performing surgery in a busy restaurant kitchen — chaotic, dangerous, and someone's going to get hurt. Branching is what lets five engineers work on five different features simultaneously without stepping on each other's toes.

Before branching existed (or when teams ignored it), a single broken commit could halt the entire team. One developer's half-finished feature would leak into production. Hotfixes required heroic weekend scrambles. Branching solves this by giving every piece of work its own isolated timeline. You can break things freely on a branch, knowing the main codebase is completely untouched until you deliberately merge your work in.

By the end of this article, you'll understand not just the commands, but why certain branching strategies exist, when to use merge versus rebase, how to resolve conflicts without panic, and how to design a branching workflow that scales with your team. These are the things that separate a developer who knows Git from one who genuinely uses it well.

How Git Branches Actually Work Under the Hood

Most tutorials tell you a branch is 'a pointer to a commit.' That's technically accurate but tells you nothing useful. Here's what it actually means: every commit in Git stores a snapshot of your entire project plus a pointer to its parent commit. A branch is simply a lightweight label — a text file containing a 40-character commit hash — that moves forward automatically every time you make a new commit on it.

This is why creating a branch in Git is nearly instant. Git isn't copying files. It's writing a 40-character string to disk. Contrast this with older systems like SVN where branching involved literally duplicating the entire codebase.

HEAD is Git's way of tracking 'where you are right now.' It normally points to a branch name, which in turn points to the latest commit on that branch. When you switch branches, Git moves HEAD and updates your working directory to match that branch's snapshot. Nothing is lost — the other branch's commits are still there, just not what HEAD is pointing at.

Understanding this model makes everything else click — why branches are cheap, why switching is fast, and why merging is just 'connecting two commit histories together.'

explore_branch_internals.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637
# ── Step 1: Initialise a fresh repo so we start clean ──────────────────────
git init branch-demo
cd branch-demo

# ── Step 2: Create an initial commit on main ────────────────────────────────
echo "v1: Initial homepage" > homepage.html
git add homepage.html
git commit -m "Add initial homepage"

# ── Step 3: See what a branch actually IS on disk ───────────────────────────
# Git stores the branch as a plain text file containing the commit hash
cat .git/refs/heads/main
# Output: a8f3c1d2e4b5f6a7b8c9d0e1f2a3b4c5d6e7f8a9  (your hash will differ)

# ── Step 4: Create a feature branch ────────────────────────────────────────
# This creates a NEW text file in .git/refs/heads/ — nothing more
git checkout -b feature/user-authentication

# ── Step 5: Confirm HEAD now points to the new branch, not main ─────────────
cat .git/HEAD
# Output: ref: refs/heads/feature/user-authentication

# ── Step 6: Make a commit on the feature branch ─────────────────────────────
echo "<form>Login form</form>" >> homepage.html
git add homepage.html
git commit -m "Add login form to homepage"

# ── Step 7: Check the branch graph — notice main is still at the old commit ─
git log --oneline --graph --all
# Output:
# * 3f9a2b1 (HEAD -> feature/user-authentication) Add login form to homepage
# * a8f3c1d (main) Add initial homepage

# ── Step 8: Switch back to main — homepage.html reverts to v1 instantly ─────
git checkout main
cat homepage.html
# Output: v1: Initial homepage
▶ Output
Initialized empty Git repository in /branch-demo/.git/
[main (root-commit) a8f3c1d] Add initial homepage
1 file changed, 1 insertion(+)
a8f3c1d2e4b5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
ref: refs/heads/feature/user-authentication
[feature/user-authentication 3f9a2b1] Add login form to homepage
1 file changed, 1 insertion(+)
* 3f9a2b1 (HEAD -> feature/user-authentication) Add login form to homepage
* a8f3c1d (main) Add initial homepage
v1: Initial homepage
⚠️
Pro Tip: Name Branches Like Tickets, Not IntentionsUse a convention like `feature/JIRA-412-user-auth`, `bugfix/JIRA-501-null-pointer`, or `hotfix/payment-gateway-timeout`. Vague names like `fix` or `my-branch` become untrackable the moment your team grows beyond two people. The ticket number ties the branch directly to a requirement or bug report.

Merge vs Rebase — Choosing the Right Strategy and Why It Matters

This is the question that trips up intermediate developers most often. Both git merge and git rebase integrate changes from one branch into another — but they produce completely different histories, and that difference has real consequences for your team.

git merge creates a new 'merge commit' that has two parents — one from each branch. The history is honest: it shows exactly when branches diverged and when they reunited. It's non-destructive — no existing commits are changed.

git rebase takes your commits and replays them on top of the target branch, as if you had started your branch from that point. The result is a perfectly linear history, as if no branching happened at all. But here's the critical thing: rebase rewrites commit hashes. Those commits are new objects with new SHAs. This is why the golden rule exists: never rebase a branch that others are working on.

The practical split most teams use: rebase to keep your local feature branch tidy and up-to-date with main before a PR, then use merge (or a squash merge) to bring the feature into main. You get clean local history AND a clear merge point on the shared branch.

merge_vs_rebase_workflow.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
# ── SETUP: Create a scenario where main moved forward while we worked ────────
git init merge-rebase-demo
cd merge-rebase-demo

# Initial commit — shared starting point
echo "API v1" > api.js
git add api.js && git commit -m "Initial API setup"

# Teammate commits directly to main (simulating their merged PR)
git checkout -b simulate-teammate-work
echo "GET /users endpoint" >> api.js
git add api.js && git commit -m "Add GET users endpoint"
git checkout main
git merge simulate-teammate-work --no-ff -m "Merge: add GET users endpoint"

# Now WE start our feature branch from the OLD main
git checkout -b feature/add-auth-middleware main~1
echo "Auth middleware logic" > middleware.js
git add middleware.js && git commit -m "Add auth middleware"

# ── OPTION A: MERGE ──────────────────────────────────────────────────────────
# Brings main INTO our feature branch — preserves full history
# Creates a merge commit showing the two branch lines converging
git checkout feature/add-auth-middleware
git merge main -m "Merge main into feature/add-auth-middleware"

git log --oneline --graph
# Output shows a 'V' shape — two lines of work merging together:
# *   d4e5f6a Merge main into feature/add-auth-middleware
# |\  
# | * b2c3d4e Merge: add GET users endpoint
# | * 9a1b2c3 Add GET users endpoint
# * | 7f8e9d0 Add auth middleware
# |/  
# * 1a2b3c4 Initial API setup

# ── OPTION B: REBASE (reset to show alternative) ─────────────────────────────
# Pretend we haven't merged yet — re-create the branch from the old main
git checkout -b feature/add-auth-middleware-rebased main~1
echo "Auth middleware logic" > middleware.js
git add middleware.js && git commit -m "Add auth middleware"

# Rebase replays our commit ON TOP of main's latest commit
# Our commit gets a brand new SHA — it's a different object now
git rebase main

git log --oneline --graph
# Output shows a perfectly straight line — looks like no branching happened:
# * e5f6a7b Add auth middleware          <- NEW SHA, replayed commit
# * b2c3d4e Merge: add GET users endpoint
# * 9a1b2c3 Add GET users endpoint
# * 1a2b3c4 Initial API setup
▶ Output
Switched to a new branch 'feature/add-auth-middleware'
[feature/add-auth-middleware 7f8e9d0] Add auth middleware
1 file changed, 1 insertion(+)
Merge made by the 'ort' strategy.
* d4e5f6a Merge main into feature/add-auth-middleware
|\
| * b2c3d4e Merge: add GET users endpoint
| * 9a1b2c3 Add GET users endpoint
* | 7f8e9d0 Add auth middleware
|/
* 1a2b3c4 Initial API setup

Successfully rebased and updated refs/heads/feature/add-auth-middleware-rebased.
* e5f6a7b Add auth middleware
* b2c3d4e Merge: add GET users endpoint
* 9a1b2c3 Add GET users endpoint
* 1a2b3c4 Initial API setup
⚠️
Watch Out: The Golden Rule of RebaseNever run `git rebase` on a branch that's been pushed and that others have checked out. When you rebase, Git creates new commit objects with new SHAs. Your teammates still have the old SHAs. When they pull, Git sees two diverged histories and your team ends up in a painful, confusing state. Reserve rebase for local cleanup only — or on personal feature branches where you're the sole author.

Resolving Merge Conflicts Without Losing Your Mind

Merge conflicts have a reputation for being scary. They're not — they're just Git being honest: 'Two people changed the same part of the same file and I don't know whose version is correct. You decide.' Git can't read your mind, so it stops and asks.

Conflicts only happen when two branches modify the same lines of the same file. If you changed line 12 and your teammate changed line 47, Git merges silently. Conflicts are actually a good sign that two people were working in overlapping areas — it's a signal to talk, not to panic.

Git marks conflicts with <<<<<<<, =======, and >>>>>>> markers directly inside the file. Everything between <<<<<<< and ======= is your version. Everything between ======= and >>>>>>> is the incoming version. Your job is to delete the markers and produce the correct final version — which might be one side, the other, or a blend of both.

The cleanest workflow: resolve the conflict in your editor (VS Code even highlights them visually), run the tests to confirm nothing broke, then git add the resolved file and git commit to finalise the merge.

conflict_resolution_walkthrough.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
# ── SETUP: Create a conflict scenario deliberately ──────────────────────────
git init conflict-demo
cd conflict-demo

# Both branches will start from this shared commit
echo "function calculateDiscount(price) {
  return price * 0.9;  // 10% discount for everyone
}" > discount.js
git add discount.js && git commit -m "Add basic discount calculation"

# ── Branch A: Marketing wants a 20% discount ────────────────────────────────
git checkout -b feature/marketing-discount
sed -i 's/price \* 0.9/price * 0.8/' discount.js  # 20% discount
git add discount.js && git commit -m "Increase discount to 20% for campaign"

# ── Branch B: Finance wants a 5% discount (back on main) ────────────────────
git checkout main
sed -i 's/price \* 0.9/price * 0.95/' discount.js  # 5% discount only
git add discount.js && git commit -m "Reduce discount to 5% per finance team"

# ── Attempt the merge — this WILL conflict ──────────────────────────────────
git merge feature/marketing-discount
# Git stops here and reports the conflict

# ── See what Git has put inside the conflicted file ─────────────────────────
cat discount.js
# Output:
# function calculateDiscount(price) {
# <<<<<<< HEAD
#   return price * 0.95;  // 10% discount for everyone
# =======
#   return price * 0.8;  // 10% discount for everyone
# >>>>>>> feature/marketing-discount
# }

# ── Resolve the conflict: agree on 15% as a compromise ──────────────────────
# In real life you'd open your editor — here we write the resolved version directly
cat > discount.js << 'EOF'
function calculateDiscount(price, isLoyalCustomer) {
  // Resolved: 15% for loyal customers (compromise between marketing and finance)
  const baseDiscount = isLoyalCustomer ? 0.85 : 0.95;
  return price * baseDiscount;
}
EOF

# ── Mark the conflict as resolved by staging the file ───────────────────────
git add discount.js

# ── Check status before committing — Git shows 'All conflicts fixed' ─────────
git status
# On branch main
# All conflicts fixed but you are still merging.
#   (use "git commit" to conclude merge)

# ── Finalise the merge with a meaningful commit message ─────────────────────
git commit -m "Merge feature/marketing-discount — compromise 15% loyal customer rate"

# ── Verify the clean history ─────────────────────────────────────────────────
git log --oneline --graph
# *   f1a2b3c Merge feature/marketing-discount — compromise 15% loyal customer rate
# |\  
# | * 4d5e6f7 Increase discount to 20% for campaign
# * | 8g9h0i1 Reduce discount to 5% per finance team
# |/  
# * 2j3k4l5 Add basic discount calculation
▶ Output
Auto-merging discount.js
CONFLICT (content): Merge conflict in discount.js
Automatic merge failed; fix conflicts and then commit the result.

On branch main
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)

* f1a2b3c Merge feature/marketing-discount — compromise 15% loyal customer rate
|\
| * 4d5e6f7 Increase discount to 20% for campaign
* | 8g9h0i1 Reduce discount to 5% per finance team
|/
* 2j3k4l5 Add basic discount calculation
🔥
Interview Gold: What git mergetool DoesRun `git mergetool` during a conflict to open a three-panel visual diff — left panel is your version, right is the incoming branch, centre is the base (common ancestor). Tools like vimdiff, VS Code, or IntelliJ integrate here. Mentioning `mergetool` in an interview signals you've resolved conflicts in real codebases, not just toy repos.

Real-World Branching Strategies — Trunk-Based vs Git Flow

Knowing the commands is only half the job. The other half is knowing which branching strategy to use for your team — because the wrong one creates more problems than having no strategy at all.

Git Flow (popularised by Vincent Driessen) uses long-lived branches: main, develop, feature/, release/, and hotfix/*. It's designed for software with scheduled releases — desktop apps, mobile apps, or libraries with versioned releases. The downside: it's complex, and long-lived feature branches are the single biggest source of painful merge conflicts.

Trunk-Based Development (TBD) has everyone commit to main (the 'trunk') either directly or via very short-lived feature branches (measured in hours or days, not weeks). Features are hidden behind feature flags until they're ready. This is how Google, Netflix, and most high-velocity teams work. It forces small, safe commits and makes CI/CD actually continuous.

For most web application teams doing continuous deployment, Trunk-Based Development wins. Git Flow is appropriate when you genuinely ship versioned software to customers who can't update instantly.

trunk_based_development_workflow.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445
# ── TRUNK-BASED DEVELOPMENT: The workflow at high-velocity teams ─────────────
# Rule: feature branches live for 1-2 days max. Merge often. Deploy from main.

# ── Step 1: Always start from an up-to-date main ────────────────────────────
git checkout main
git pull origin main   # Never start work on stale code

# ── Step 2: Create a short-lived, focused feature branch ────────────────────
# This branch should do ONE thing. Not 'refactor everything.'
git checkout -b feature/PROJ-88-add-password-reset

# ── Step 3: Make small, atomic commits — each commit should be deployable ───
echo "POST /auth/password-reset" >> routes.js
git add routes.js
git commit -m "PROJ-88: Add password reset route"

echo "sendResetEmail(user.email, resetToken)" >> emailService.js
git add emailService.js
git commit -m "PROJ-88: Wire reset token to email service"

# ── Step 4: Rebase onto latest main before opening PR ───────────────────────
# This ensures your PR has zero conflicts when it lands
git fetch origin
git rebase origin/main
# If conflicts arise here, resolve them now — not after code review

# ── Step 5: Push and open a Pull Request ────────────────────────────────────
git push origin feature/PROJ-88-add-password-reset
# Open PR on GitHub/GitLab — request review — CI runs automatically

# ── Step 6: After approval, use SQUASH MERGE on GitHub/GitLab ───────────────
# Squash merge collapses all your commits into one clean commit on main
# This keeps main's history readable — one entry per feature
# Equivalent CLI command:
git checkout main
git merge --squash feature/PROJ-88-add-password-reset
git commit -m "PROJ-88: Add password reset flow (route + email service)"

# ── Step 7: Delete the branch — it has served its purpose ───────────────────
git branch -d feature/PROJ-88-add-password-reset
git push origin --delete feature/PROJ-88-add-password-reset

# ── Step 8: Deploy immediately — main is always production-ready ─────────────
# Your CI/CD pipeline triggers automatically on merge to main
echo "main is now deployed to production automatically via CI/CD"
▶ Output
Already on 'main'
Your branch is up to date with 'origin/main'.
Switched to a new branch 'feature/PROJ-88-add-password-reset'
[feature/PROJ-88-add-password-reset a1b2c3d] PROJ-88: Add password reset route
[feature/PROJ-88-add-password-reset e4f5g6h] PROJ-88: Wire reset token to email service
Successfully rebased and updated refs/heads/feature/PROJ-88-add-password-reset.
Branch 'feature/PROJ-88-add-password-reset' set up to track 'origin/feature/PROJ-88-add-password-reset'.
Squash commit -- not updating HEAD
[main 7h8i9j0] PROJ-88: Add password reset flow (route + email service)
Deleted branch feature/PROJ-88-add-password-reset (was e4f5g6h).
To github.com:yourorg/yourproject.git
- [deleted] feature/PROJ-88-add-password-reset
main is now deployed to production automatically via CI/CD
⚠️
Pro Tip: Protect Your Main BranchIn GitHub/GitLab, enable branch protection rules on `main`: require at least one approval, require CI to pass, and disable force-pushing. This takes 30 seconds to set up and prevents the two most common team disasters — accidentally pushing broken code and rewriting shared history. It's not bureaucracy, it's safety rails.
AspectGit MergeGit Rebase
History shapeNon-linear — shows actual branch structure with merge commitsLinear — looks like all commits happened sequentially
Existing commits changed?No — all original commits and SHAs preservedYes — commits are replayed with new SHAs
Safe on shared branches?Yes — always safe to merge into a shared branchNo — never rebase a branch others have checked out
Conflict resolutionResolve once in the merge commitResolve per commit being replayed (can be repetitive)
Best used forIntegrating a completed feature branch into mainKeeping a local feature branch up-to-date before a PR
Audit trailClear record of when branches diverged and mergedHidden — no record that branching occurred
Team size suitabilityWorks at any team sizeBest for individual developers on their own branches

🎯 Key Takeaways

  • A Git branch is just a 40-character pointer to a commit — creating one is essentially free and you should branch early and often rather than working directly on main.
  • Merge preserves honest history (use it for integrating features into shared branches); rebase creates a clean linear history (use it only on your own local branches before opening a PR).
  • Merge conflicts are not emergencies — they're Git asking a human to make a decision. Resolve them by editing the file to the correct state, staging it, then completing the merge with a descriptive commit message.
  • Your branching strategy should match your deployment model: Trunk-Based Development for continuous deployment, Git Flow for scheduled versioned releases — most modern web teams should default to trunk-based.

⚠ Common Mistakes to Avoid

  • Mistake 1: Force-pushing a rebased branch to a shared remote — Symptom: teammates get 'diverged history' errors and can't pull without chaos, often seeing 'fatal: refusing to merge unrelated histories' — Fix: only rebase branches that exist solely on your local machine or that only you are working on. If you must update a PR branch after rebase, coordinate with your team and have them re-checkout the branch after your force-push (git push --force-with-lease is safer than --force as it checks nobody else pushed in the meantime).
  • Mistake 2: Creating one giant branch for an entire feature sprint — Symptom: a 3-week-old branch with 200 file changes and a PR nobody wants to review, plus merge conflicts on nearly every file — Fix: break features into vertical slices. A login flow becomes three branches: feature/login-api-endpoint, feature/login-ui-form, feature/login-session-management. Each is reviewable in under 30 minutes and mergeable within a few days.
  • Mistake 3: Forgetting to delete merged branches — Symptom: git branch -a shows 80 branches, half of which are stale features from 6 months ago — nobody knows which are safe to delete — Fix: delete the remote branch immediately after merge (git push origin --delete branch-name). Most GitHub/GitLab merge UIs have a 'Delete branch after merge' checkbox — enable it by default. Stale branches are noise that slows down the whole team.

Interview Questions on This Topic

  • QWhat's the difference between `git merge` and `git rebase`, and when would you choose one over the other in a team environment?
  • QWalk me through how you'd handle a situation where two developers have both modified the same file on separate branches and a merge conflict occurs.
  • QIf a colleague tells you they rebased the `main` branch to clean up history, what's your immediate concern and what steps would you take?

Frequently Asked Questions

What happens if I delete a branch in Git — do I lose my commits?

No. Commits are stored independently of branches in Git. Deleting a branch just removes the label pointing to those commits — the commits themselves remain in Git's object store for at least 30 days (until garbage collection). You can recover them using git reflog to find the SHA, then git checkout -b recovery-branch . That said, after garbage collection the commits are gone for good, so don't rely on this as a backup strategy.

What is a detached HEAD state in Git and how do I fix it?

Detached HEAD means HEAD is pointing directly at a commit SHA instead of a branch name. This happens when you run git checkout . Any commits you make in this state are technically orphaned — no branch tracks them. To fix it, run git checkout -b new-branch-name to create a branch at your current position, which re-attaches HEAD and saves your work. Then carry on as normal.

Should I use `git merge --no-ff` or a regular fast-forward merge when bringing in a feature branch?

Use --no-ff (no fast-forward) when merging feature branches into main. A fast-forward merge slides the commits directly onto main with no merge commit, making it impossible to tell later that a branch ever existed. The --no-ff flag forces a merge commit, preserving the record that this group of commits was a discrete feature. Most GitHub/GitLab PR merges create a merge commit by default, which is equivalent to --no-ff.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousIntroduction to GitNext →Git Rebase vs Merge
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged