Skip to content
Home DevOps Git Branching and Merging: Commands, Strategies and Examples

Git Branching and Merging: Commands, Strategies and Examples

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Git → Topic 2 of 19
Create, switch and merge branches.
⚙️ Intermediate — basic DevOps knowledge assumed
In this tutorial, you'll learn
Create, switch and merge branches.
  • Branches are cheap pointers to commits — create them freely. They cost nothing in Git.
  • Merge preserves commit SHAs and creates a DAG history. Rebase creates linear history but rewrites all commit SHAs.
  • Never rebase commits that have been pushed to a shared branch. It breaks every teammate's local history.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • git checkout -b feature/name — create and switch to a new branch
  • git merge feature/x — integrate branch changes into current branch
  • git rebase main — replay your commits on top of main's latest state
  • --no-ff — force a merge commit even when fast-forward is possible
🚨 START HERE
Git Branch and Merge Triage Cheat Sheet
Fast recovery for branch-related production incidents.
🟡'Your branch and origin/<branch> have diverged' after teammate's force-push
Immediate ActionReset local branch to match remote.
Commands
git fetch origin
git reset --hard origin/<branch>
Fix NowIf you had local commits: git reflog to find them, then cherry-pick onto the reset branch.
🟡Merge conflict — too many files to resolve manually
Immediate ActionAbort the merge and consider a different strategy.
Commands
git merge --abort (or git rebase --abort)
git log --oneline --graph HEAD <branch> to understand the divergence
Fix NowIf rebasing: squash your commits first to reduce conflict points. If merging: consider merging develop into your branch more frequently.
🟡Feature branch parent points to a SHA that no longer exists on develop
Immediate ActionRebase the feature branch onto the current develop tip.
Commands
git fetch origin && git log --oneline origin/develop -5
git rebase --onto origin/develop <old-parent-sha> <feature-branch-tip>
Fix NowIf the old parent SHA is unknown: git merge-base HEAD origin/develop to find the closest common ancestor.
🟡git merge says 'Already up to date' but changes exist on the source branch
Immediate ActionVerify you're on the correct branch and merging from the correct source.
Commands
git branch --show-current && git log --oneline -3
git log --oneline --graph --all to see the full branch topology
Fix NowIf changes were squash-merged: Git considers the content already merged. Create a new branch from develop if you need those changes.
🟡After rebase, commits appear duplicated or in wrong order
Immediate ActionUndo the rebase using reflog.
Commands
git reflog | head -20
git reset --hard HEAD@{1} (or the pre-rebase entry)
Fix NowRe-run rebase with the correct base branch. Verify with git log --oneline --graph before pushing.
Production IncidentRebase on Shared Branch: 12 Engineers Lose 3 Hours of WorkA senior engineer rebased the develop branch to 'clean up history' after a messy merge. The rebase rewrote 8 commits. Twelve engineers who had pulled develop now had divergent local histories. Three had based feature branches on commit SHAs that no longer existed.
SymptomMultiple engineers reported 'Your branch and 'origin/develop' have diverged' on their next pull. Three engineers' feature branches showed merge conflicts against develop that didn't exist before. CI reported 'commit not found' for a webhook payload referencing a SHA from before the rebase.
AssumptionThe senior engineer assumed rebasing develop was safe because 'nobody had pushed since the last merge.' They did not realize that 12 engineers had already pulled the pre-rebase develop and based their work on those commit SHAs.
Root cause1. The engineer ran git rebase main on the develop branch to linearize 8 merge commits. 2. This rewrote all 8 commit SHAs. The new commits had the same content but different hashes. 3. git push origin develop was rejected (non-fast-forward). The engineer used git push --force. 4. The remote develop now pointed to the rebased commits. The old 8 commits were orphaned on the remote. 5. Twelve engineers had the old commits in their local develop. Their next git pull showed divergence. 6. Three engineers had feature branches with parents pointing to old develop SHAs. Those parent references were now dangling — their branches were effectively orphaned from develop's history. 7. CI webhook payloads referenced the old SHA of the last pre-rebase commit, which no longer existed on the remote.
Fix1. Identified the last pre-rebase commit SHA from CI webhook logs. 2. Force-pushed the old commit chain back to develop: git reset --hard <pre-rebase-sha> && git push origin develop --force-with-lease. 3. For the 12 engineers with diverged local develop: git fetch origin && git reset --hard origin/develop. 4. For the 3 engineers with orphaned feature branches: used git rebase --onto origin/develop <old-parent> <branch-tip> to replay their feature commits on top of the restored develop. 5. Re-triggered CI manually. 6. Added branch protection rules requiring PR reviews for develop — preventing direct pushes entirely.
Key Lesson
Never rebase a branch that other people pull from. The golden rule exists because rewritten SHAs break every clone that pulled the old history.--force is never acceptable on shared branches. --force-with-lease would have been irrelevant here (nobody else pushed), but normalizing --force creates dangerous habits.Branch protection rules that prevent direct pushes to shared branches would have prevented this entirely.Feature branches with parent pointers to specific SHAs are fragile. Always rebase feature branches onto the latest develop before merging, rather than relying on fixed parent SHAs.
Production Debug GuideSystematic recovery paths for history corruption, merge conflicts, and divergent branches.
'Your branch and origin/<branch> have diverged' after a teammate force-pushed1. Run git fetch origin to see what the remote has. 2. Run git log --oneline origin/<branch> to inspect the remote's history. 3. If the remote history looks correct: git reset --hard origin/<branch> to align your local branch. 4. If you had local commits on top of the old history: find them via git reflog, then cherry-pick or rebase them onto the new branch tip. 5. Prevention: never force-push to branches others pull from without coordinating first.
Merge conflict in a file you didn't modify1. This happens when both branches modified adjacent lines or when one branch renamed/moved a file the other modified. 2. Run git diff to see the full conflict context. 3. Check if the conflict is from whitespace changes: git diff --ignore-all-space. 4. If the conflict is from a rebase: your commits are being replayed — resolve and continue with git rebase --continue. 5. If too many conflicts: abort with git merge --abort or git rebase --abort and consider a different integration strategy.
Feature branch has merge conflicts with develop that seem unrelated to your changes1. Your feature branch diverged from an old point on develop. Other changes on develop modified the same files. 2. Run git merge-base HEAD develop to see where your branch diverged. 3. Rebase your branch onto the latest develop: git rebase develop. 4. Resolve conflicts one commit at a time during the rebase. 5. If the rebase has too many conflicts: consider squashing your commits first (git rebase -i --autosquash develop) to reduce the number of conflict resolution points.
git merge says 'Already up to date' but you know there are changes to merge1. The branch you're merging is already fully contained in your current branch. This happens when you previously merged or rebased. 2. Run git log --oneline --graph --all to see the actual branch topology. 3. Check if you're on the correct branch: git branch --show-current. 4. If the changes are on a different branch than expected: switch to the correct branch and merge from there. 5. If the changes were squash-merged: the commits are different but the content is the same. Git sees the content as already merged.
After rebase, git log shows duplicate commits or unexpected commit order1. You may have rebased onto the wrong base, or rebased twice (creating duplicate commits). 2. Run git reflog to see the sequence of operations. 3. Identify the commit SHA before the bad rebase started. 4. git reset --hard <pre-rebase-sha> to undo the rebase. 5. Re-run the rebase with the correct base: git rebase <correct-base-branch>.

Branching enables parallel development by isolating changes into independent lines of history. Each branch is a pointer to a commit — creating one costs almost nothing in Git because no files are copied.

The merge vs rebase decision affects how your repository's history reads during code review and debugging. Merge preserves the exact timeline of how work happened. Rebase creates a clean linear narrative. Both are used in production teams — the choice depends on your team's workflow and tooling.

Common misconceptions: that rebase is always better because it produces cleaner history, that fast-forward merges are always preferable, and that branches are expensive to create. Understanding the trade-offs prevents the force-push incidents and history corruption that plague teams adopting Git without training.

Creating and Managing Branches

A Git branch is a pointer to a commit. Creating a branch does not copy files — it creates a 41-byte reference file in .git/refs/heads/. This is why branches are cheap: you can create hundreds without meaningful storage or performance cost.

The pointer moves forward automatically when you make new commits on the branch. When you switch branches (git checkout or git switch), Git updates your working directory to match the commit the branch points to.

Branch naming conventions matter for tooling. Most CI systems, PR platforms, and branch protection rules use pattern matching (feature/, release/, hotfix/*). Consistent naming enables automated workflows — branch protection rules, auto-deletion after merge, and CI trigger filters all depend on naming patterns.

io/thecodeforge/git/BranchManagement.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
# io.thecodeforge — Branch Management

# ─────────────────────────────────────────────────────────────
# CREATE AND SWITCH
# ─────────────────────────────────────────────────────────────

# Modern syntax (Git 2.23+)
git switch -c feature/payment-retry
# Creates branch and switches to it.

# Legacy syntax (still widely used)
git checkout -b feature/payment-retry

# Create branch from a specific commit
git switch -c hotfix/critical-bug abc1234
# Branch starts from commit abc1234, not HEAD.

# ─────────────────────────────────────────────────────────────
# LIST AND INSPECT
# ─────────────────────────────────────────────────────────────

git branch          # local branches (current branch marked with *)
git branch -r       # remote-tracking branches
git branch -a       # all branches (local + remote)
git branch -v       # branches with last commit info
git branch --merged main  # branches already merged into main
git branch --no-merged main  # branches not yet merged into main

# Find branches containing a specific commit
git branch --contains abc1234
# Useful when you need to know which branch introduced a bug.

# ─────────────────────────────────────────────────────────────
# PUSH AND TRACK
# ─────────────────────────────────────────────────────────────

# First push: set upstream tracking
git push -u origin feature/payment-retry
# -u sets upstream so future 'git push' and 'git pull' work without arguments.

# Subsequent pushes (upstream already set)
git push
# Automatically pushes to origin/feature/payment-retry.

# Push all local branches
git push origin --all

# ─────────────────────────────────────────────────────────────
# DELETE AND CLEAN UP
# ─────────────────────────────────────────────────────────────

# Delete local branch (safe — refuses if unmerged changes exist)
git branch -d feature/payment-retry

# Force delete local branch (discards unmerged changes)
git branch -D feature/payment-retry

# Delete remote branch
git push origin --delete feature/payment-retry

# Prune stale remote-tracking references
git remote prune origin
# Removes references to remote branches that have been deleted.

# Auto-delete after merge (GitHub/GitLab setting)
# Most platforms delete the branch automatically after PR merge.
# This keeps the remote clean without manual intervention.

# ─────────────────────────────────────────────────────────────
# RENAME
# ─────────────────────────────────────────────────────────────

# Rename current branch
git branch -m new-name

# Rename a different branch
git branch -m old-name new-name
▶ Output
# git branch -v
# * feature/payment-retry a1b2c3d Add PaymentRetryService
# main 8d1e3f5 Release v2.4.0
# feature/user-auth e4f5g6h Add OAuth2 integration

# git branch --no-merged main
# * feature/payment-retry
# feature/user-auth

# git branch --contains e4f5g6h
# feature/user-auth
Mental Model
Branches Are Pointers, Not Copies
Think of branches as bookmarks in a book, not photocopies of the book.
  • Creating a branch: one 41-byte file in .git/refs/heads/
  • Switching branches: Git updates the working directory to match the target commit
  • Deleting a branch: removes the pointer file. The commits remain until garbage-collected.
  • Branch naming patterns enable CI/CD automation: feature/, release/, hotfix/*
📊 Production Insight
Branch naming conventions are not cosmetic — they drive automation. CI systems use glob patterns to trigger builds (feature/ triggers PR validation, release/ triggers staging deploy). Branch protection rules use patterns to enforce policies (main requires 2 reviewers, develop requires 1). Inconsistent naming (feat/login vs feature/login vs login-feature) breaks these automations silently. Teams should enforce naming conventions via pre-push hooks or CI linting, not documentation.
🎯 Key Takeaway
Branches are cheap pointers to commits — create them freely. Branch naming conventions drive CI/CD automation and branch protection rules. Use git branch --no-merged main to find branches that haven't been integrated yet. Auto-delete merged branches to keep the remote clean.

Merge vs Rebase

Merge and rebase both integrate changes from one branch into another. They produce different histories, and the choice affects code review, debugging, and collaboration.

Merge creates a merge commit — a special commit with two parents. It preserves the exact history of how work happened: when the branch was created, what commits were made on it, and when it was integrated. The history is a DAG (directed acyclic graph), not a straight line.

Rebase replays your commits on top of another branch. Each commit is cherry-picked onto the new base, creating new commits with new SHAs. The result is a linear history — no merge commits, no branching visible in git log. But the original commit SHAs are gone, which breaks any reference to them.

The golden rule: never rebase commits that have been pushed to a shared branch. Rebasing rewrites commit SHAs. Every teammate who pulled the old SHAs now has references to commits that no longer exist on the remote.

io/thecodeforge/git/MergeVsRebase.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
# io.thecodeforge — Merge vs Rebase

# ─────────────────────────────────────────────────────────────
# MERGE: Preserves exact history
# ─────────────────────────────────────────────────────────────

# Standard merge (fast-forward if possible, merge commit if diverged)
git checkout main
git merge feature/payment-retry

# Force merge commit even when fast-forward is possible
git merge --no-ff feature/payment-retry
# Creates a merge commit that groups all feature branch commits.
# Useful for traceability: git log --first-parent main shows
# only merge commits, giving a high-level integration timeline.

# Squash merge: collapse all feature commits into one
git merge --squash feature/payment-retry
git commit -m "feat(payment): Add PaymentRetryService"
# All changes from the feature branch are staged.
# You create a single commit on main with the combined changes.
# The feature branch's individual commit history is lost on main.

# ─────────────────────────────────────────────────────────────
# REBASE: Linearises history
# ─────────────────────────────────────────────────────────────

# Rebase feature branch onto latest main
git checkout feature/payment-retry
git rebase main
# Your commits are replayed on top of main's latest commit.
# Each commit gets a new SHA.
# Then fast-forward merge:
git checkout main
git merge feature/payment-retry  # fast-forward, linear history

# Interactive rebase: squash, reorder, edit commits before merging
git rebase -i main
# Editor opens with:
# pick a1b2c3d Add PaymentRetryService
# pick e4f5g6h Add retry config validation
# pick i7j8k9l Fix typo in RetryConfig
#
# Change to:
# pick a1b2c3d Add PaymentRetryService
# squash e4f5g6h Add retry config validation
# fixup i7j8k9l Fix typo in RetryConfig
#
# Result: one clean commit combining all three.

# ─────────────────────────────────────────────────────────────
# THE GOLDEN RULE
# ─────────────────────────────────────────────────────────────

# SAFE: rebase local-only branches (never pushed)
git rebase main  # on a branch only you use

# DANGEROUS: rebase pushed shared branches
git rebase main  # on develop, which 12 engineers pull from
# Then: git push --force  ← THIS BREAKS EVERYONE

# IF YOU MUST rewrite shared history:
# 1. Coordinate with the team first
# 2. Use --force-with-lease (never --force)
# 3. Have everyone fetch and reset after your push
▶ Output
# git merge --no-ff feature/payment-retry
# Merge made by the 'ort' strategy.
# src/main/java/io/thecodeforge/payment/PaymentRetryService.java | 87 +++
# 1 file changed, 87 insertions(+)

# git rebase main (on feature branch)
# Successfully rebased and updated refs/heads/feature/payment-retry.
# All commits now have new SHAs.
Mental Model
Merge = Tape Together; Rebase = Rewrite From Scratch
Merge preserves identity. Rebase preserves narrative.
  • Merge: original commits untouched, merge commit added. DAG history.
  • Rebase: all commits replayed with new SHAs. Linear history.
  • Squash merge: collapses feature commits into one. Simplest history, loses granularity.
  • The golden rule: never rebase commits that others have pulled.
📊 Production Insight
The merge vs rebase choice affects git bisect effectiveness. With merge commits, git log --first-parent main shows a clean integration timeline — each merge commit represents a feature. With rebase, all commits appear linearly, mixing feature work with bug fixes. This makes git bisect more powerful (finer granularity) but git log harder to read. Teams using rebase should enforce squash-before-rebase to keep the linear history readable. Teams using merge should use --no-ff to preserve branch grouping in the history.
🎯 Key Takeaway
Merge preserves commit SHAs and creates a DAG history showing how work was integrated. Rebase creates linear history by replaying commits with new SHAs. Never rebase pushed shared branches — it breaks every teammate's local history. Use squash merge for simple integration, --no-ff merge for traceability, and rebase for local branch cleanup before PR.

Resolving Merge Conflicts

Merge conflicts occur when both branches modify the same lines of a file, or when one branch deletes a file the other modifies. Git cannot automatically decide which version to keep — it marks the file with conflict markers and pauses the merge.

Conflict markers show three versions: the current branch's version (<<<<<<< HEAD), a separator (=======), and the incoming branch's version (>>>>>>> feature/branch). Your job is to edit the file to produce the correct result, removing the markers.

Resolving conflicts is not just about picking one version over the other. Often the correct resolution combines both changes — for example, if both branches added different imports to the same file, you need both imports in the resolved version.

After resolving all conflicts, stage the files and commit. Git creates the merge commit automatically if all conflicts are resolved.

io/thecodeforge/git/MergeConflicts.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
# io.thecodeforge — Resolving Merge Conflicts

# ─────────────────────────────────────────────────────────────
# CONFLICT DURING MERGE
# ─────────────────────────────────────────────────────────────

git merge feature/payment-retry
# CONFLICT (content): Merge conflict in src/main/java/io/thecodeforge/payment/PaymentService.java
# Automatic merge failed; fix conflicts and then commit the result.

# Check which files have conflicts
git status
# Both modified: src/main/java/io/thecodeforge/payment/PaymentService.java

git diff --name-only --diff-filter=U
# Shows only files with unresolved conflicts.

# ─────────────────────────────────────────────────────────────
# CONFLICT MARKERS IN THE FILE
# ─────────────────────────────────────────────────────────────

# <<<<<<< HEAD (your current branch)
# public PaymentResult processPayment(Order order) {
#     return paymentClient.charge(order.getAmount(), order.getCurrency());
# =======
# public PaymentResult processPayment(Order order, boolean retryEnabled) {
#     if (retryEnabled) {
#         return retryService.chargeWithRetry(order);
#     }
#     return paymentClient.charge(order.getAmount(), order.getCurrency());
# }
# >>>>>>> feature/payment-retry

# Edit the file to produce the correct combined result:
# public PaymentResult processPayment(Order order, boolean retryEnabled) {
#     if (retryEnabled) {
#         return retryService.chargeWithRetry(order);
#     }
#     return paymentClient.charge(order.getAmount(), order.getCurrency());
# }

# ─────────────────────────────────────────────────────────────
# RESOLVE AND COMPLETE
# ─────────────────────────────────────────────────────────────

# Stage the resolved file
git add src/main/java/io/thecodeforge/payment/PaymentService.java

# Complete the merge
git commit
# Git auto-generates a merge commit message.
# Or provide your own:
git commit -m "Merge featuren
# ─────────────────────────/payment-retry: resolve PaymentService conflict"\────────────────────────────────────
# ABORT: If conflicts are too complex
# ─────────────────────────────────────────────────────────────

git merge --abort
# Cancels the merge. Working directory restored to pre-merge state.
# Safe to use at any point during conflict resolution.

# ─────────────────────────────────────────────────────────────
# CONFLICT DURING REBASE
# ─────────────────────────────────────────────────────────────

# If conflicts occur during rebase:
git rebase main
# CONFLICT (content): Merge conflict in PaymentService.java

# Resolve the conflict, then:
git add src/main/java/io/thecodeforge/payment/PaymentService.java
git rebase --continue
# Git moves to the next commit in the rebase sequence.
# More conflicts may occur in subsequent commits.

# Skip a commit during rebase (if it's not important):
git rebase --skip
# Discards the current commit's changes entirely.

# Abort the entire rebase:
git rebase --abort
# Returns to the state before the rebase started.

# ─────────────────────────────────────────────────────────────
# MERGE TOOLS
# ─────────────────────────────────────────────────────────────

# Configure a merge tool
git config --global merge.tool vimdiff
# Or: vscode, meld, p4merge, kdiff3, beyondcompare

# Launch merge tool for all conflicted files
git mergetool
# Opens the configured tool with three panes:
# LOCAL (your version), REMOTE (incoming version), BASE (common ancestor)
▶ Output
# git status during conflict
# On branch main
# You have unmerged paths.
# (fix conflicts and run "git commit")
#
# Unmerged paths:
# (use "git add <file>..." to mark resolution)
#
# both modified: src/main/java/io/thecodeforge/payment/PaymentService.java
#
# no changes added to commit
# (use "git add" and/or "git commit -a")
Mental Model
Conflicts Are Communication Failures, Not Git Failures
Conflicts are symptoms of poor coordination or poor modular design.
  • Conflict markers show three versions: yours, theirs, and the common ancestor
  • Resolving often requires combining both changes, not picking one
  • After resolving: always compile and test before committing the merge
  • Frequent conflicts in the same files indicate the code needs refactoring into separate modules
📊 Production Insight
Conflict resolution is where bugs are introduced. The person resolving the conflict often picks one version and discards the other without understanding both changes. This creates regressions: a fix from one branch is silently dropped during conflict resolution. Best practice: after resolving conflicts, run the full test suite before committing the merge. Better practice: have the conflict resolution reviewed as part of the PR — some teams add a 'conflict resolution' section to PR descriptions showing what was changed and why.
🎯 Key Takeaway
Merge conflicts occur when both branches modify the same code. Edit the file to produce the correct result, removing conflict markers. Stage and commit to complete the merge. Use git merge --abort to cancel. After resolving, always compile and test — conflict resolution is a common source of regression bugs.

Fast-Forward vs 3-Way Merge

A fast-forward merge happens when the target branch's HEAD is a direct ancestor of the source branch. Git simply moves the target pointer forward — no merge commit is created. The history remains linear.

A 3-way merge happens when both branches have diverged — each has commits the other doesn't. Git creates a merge commit with two parents, combining the changes from both branches. This requires finding the common ancestor (merge base) and computing the diff from that point.

The --no-ff flag forces a merge commit even when fast-forward is possible. This is useful for traceability: git log --first-parent main shows only merge commits, giving a clean integration timeline. Without --no-ff, feature branch commits appear directly in main's history with no grouping.

io/thecodeforge/git/FastForwardVs3Way.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
# io.thecodeforge — Fast-Forward vs 3-Way Merge

# ─────────────────────────────────────────────────────────────
# FAST-FORWARD: No divergence, pointer just moves forward
# ─────────────────────────────────────────────────────────────

# Scenario: main is at commit A. You branched feature from A.
# You made commits B and C on feature. main is still at A.
# No commits were added to main since you branched.

git checkout main
git merge feature/payment-retry
# Fast-forward
# Updating 8d1e3f5..a1b2c3d
# Fast-forward
#  src/main/java/io/thecodeforge/payment/PaymentRetryService.java | 87 +++

# Result: main now points to commit C. No merge commit created.
# git log shows: C → B → A (linear)

# Force a merge commit even with fast-forward
git merge --no-ff feature/payment-retry
# Result: Merge commit M with parents C and A.
# git log shows: M → C → B → A (with branch visible)

# ─────────────────────────────────────────────────────────────
# 3-WAY MERGE: Both branches diverged
# ─────────────────────────────────────────────────────────────

# Scenario: main is at commit A. You branched feature from A.
# You made B and C on feature. Meanwhile, someone added D on main.
# Both branches diverged from A.

git checkout main
git merge feature/payment-retry
# Merge made by the 'ort' strategy.
#  src/main/java/io/thecodeforge/payment/PaymentRetryService.java | 87 +++
#  1 file changed, 87 insertions(+)

# Result: Merge commit M with parents C (feature) and D (main).
# The merge base is A. Git computes: diff(A→C) + diff(A→D) and combines them.

# ─────────────────────────────────────────────────────────────
# VIEWING THE MERGE BASE
# ─────────────────────────────────────────────────────────────

git merge-base main feature/payment-retry
# Output: <sha-of-commit-A>
# This is the common ancestor used for the 3-way merge.

# ─────────────────────────────────────────────────────────────
# FIRST-PARENT LOG: See only merge commits on main
# ─────────────────────────────────────────────────────────────

git log --first-parent main --oneline
# Shows only commits on main's direct lineage.
# Merge commits appear, but individual feature commits don't.
# This gives a high-level integration timeline.
# Only works well if you use --no-ff merges.
▶ Output
# Fast-forward: pointer moves from A to C. No merge commit.
# 3-way merge: merge commit M created with parents C and D.
# --no-ff: forces merge commit even when fast-forward is possible.
Mental Model
Fast-Forward = Train Moving Forward; 3-Way Merge = Two Tracks Converging
Fast-forward is a pointer move. 3-way merge is a content combination.
  • Fast-forward: no merge commit, linear history, pointer just moves forward
  • 3-way merge: merge commit with two parents, DAG history, content combined
  • --no-ff forces a merge commit for traceability even when fast-forward is possible
  • git log --first-parent main shows only the integration timeline (merge commits)
📊 Production Insight
The --no-ff flag is a team policy decision, not a technical one. Teams that use --no-ff get a clean git log --first-parent main that shows exactly when features were integrated. Teams that allow fast-forward get a linear history that's easier to read with git log but harder to trace which commits belong to which feature. For release management and changelog generation, --no-ff is superior because each merge commit represents a complete feature. For simple projects with few contributors, fast-forward is simpler and sufficient.
🎯 Key Takeaway
Fast-forward merges move the pointer forward when there's no divergence — no merge commit created. 3-way merges combine changes from diverged branches using a common ancestor. Use --no-ff to force merge commits for traceability. git log --first-parent main shows only the integration timeline when using --no-ff merges.

Branch Strategies: GitFlow, GitHub Flow, and Trunk-Based Development

Branch strategy defines how your team uses branches for development, releases, and hotfixes. The three dominant strategies are GitFlow, GitHub Flow, and trunk-based development. Each has different trade-offs for release cadence, code stability, and operational complexity.

GitFlow uses long-lived branches (main, develop) and short-lived branches (feature, release, hotfix). It's designed for scheduled releases with strict version management. The overhead is significant — multiple branch types, merge ordering rules, and release branch management.

GitHub Flow is simpler: one long-lived branch (main), short-lived feature branches, and deploy-from-main. Every merge strong CI/CD and feature flags for incomplete work.

Trunk-based development uses very short-lived branches (hours, not days) or direct commits to main. Feature flags control incomplete work. It maximizes integration frequency and minimizes merge conflicts but requires mature CI/CD and feature flag infrastructure.

io/thecodeforge/git/BranchStrategies.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
# io.thecodeforge — Branch Strategies

# ─────────────────────────────────────────────────────────────
# GITFLOW
# ─────────────────────────────────────────────────────────────

# Branches: main, develop, feature/*, release/*, hotfix/*

# Start a feature
git checkout develop
git checkout -b feature/payment-retry

# Finish a feature: merge into develop
git checkout develop
git merge --no-ff feature/payment-retry
git branch -d feature/payment-retry

# Start a release
git checkout develop
git checkout -b release/v2.5.0
# Bug fixes only on release branch

# Finish a release: merge into main works well for continuous to main triggers a deploy. It deployment but requires AND develop
git checkout main
git merge --no-ff release/v2.5.0
git tag -a v2.5.0 -m "Release v2.5.0"
git checkout develop
git merge --no-ff release/v2.5.0
git branch -d release/v2.5.0

# Hotfix: branch from main, merge back to main AND develop
git checkout main
git checkout -b hotfix/critical-bug
# Fix the bug
git checkout main
git merge --no-ff hotfix/critical-bug
git tag -a v2.5.1 -m "Hotfix v2.5.1"
git checkout develop
git merge --no-ff hotfix/critical-bug

# ─────────────────────────────────────────────────────────────
# GITHUB FLOW
# ─────────────────────────────────────────────────────────────

# Branches: main, feature/* (short-lived)

# Create feature branch from main
git checkout main
git checkout -b feature/payment-retry

# Push and open PR
git push -u origin feature/payment-retry
# Open PR on GitHub. CI runs automatically.

# After PR approval: merge into main (or squash merge)
# GitHub merges the PR. Main is deployed automatically.

# Delete feature branch after merge
git branch -d feature/payment-retry

# ─────────────────────────────────────────────────────────────
# TRUNK-BASED DEVELOPMENT
# ─────────────────────────────────────────────────────────────

# Branches: main (trunk), very short-lived branches (hours)

# Create short-lived branch
git checkout -b feature/payment-retry

# Commit frequently (multiple times per day)
git add src/main/java/io/thecodeforge/payment/PaymentRetryService.java
git commit -m "WIP: PaymentRetryService skeleton"

# Rebase onto main before merging (to stay current)
git fetch origin
git rebase origin/main

# Merge into main via PR (or direct push with CI gates)
git checkout main
git merge --ff-only feature/payment-retry
# --ff-only ensures no merge commit. Linear history.

# Feature flags for incomplete work
# In application code:
# if (featureFlags.isEnabled("payment-retry")) {
#     return retryService.chargeWithRetry(order);
# }
# The feature is merged to main but hidden behind a flag.
▶ Output
# GitFlow: structured, versioned, high overhead
# GitHub Flow: simple, deploy-from-main, requires strong CI
# Trunk-based: fast integration, requires feature flags and mature CI
Mental Model
Branch Strategy = Release Cadence Decision
Choose strategy based on release cadence, not team preference.
  • GitFlow: main + develop + feature/release/hotfix branches. Scheduled releases. High overhead.
  • GitHub Flow: main + short-lived feature branches. Deploy on merge. Simple.
  • Trunk-based: main + very short-lived branches (hours). Feature flags. Maximum integration frequency.
  • Most teams at scale use trunk-based development with feature flags.
📊 Production Insight
Branch strategy affects merge conflict frequency. GitFlow's long-lived develop branch accumulates changes between releases, creating large merge conflicts when release branches are created. GitHub Flow's short-lived branches merge frequently, keeping conflicts small. Trunk-based development's branches last hours, making conflicts nearly impossible. The trade-off: shorter branches require stronger CI/CD (every merge must be tested) and feature flag infrastructure (for incomplete work). Teams without these capabilities should not attempt trunk-based development — it will produce broken main branches.
🎯 Key Takeaway
Branch strategy should match your release cadence: GitFlow for scheduled releases, GitHub Flow for continuous deployment, trunk-based for high-frequency integration. Shorter branches mean fewer merge conflicts but require stronger CI/CD and feature flag infrastructure. Most large-scale teams use trunk-based development.
🗂 Merge vs Rebase vs Squash Merge
Choose based on your team's history preferences and tooling.
Aspectgit mergegit rebasegit merge --squash
History shapeDAG (merge commits show branching)Linear (no merge commits)Linear (single commit per feature)
Commit SHAs preservedYes — original commits keep their SHAsNo — all replayed commits get new SHAsN/A — only one new commit created
Safe on shared branchesYes — does not rewrite historyNo — rewrites history, breaks referencesYes — creates new commit, no rewrite
TraceabilityHigh — merge commits group feature workMedium — linear but commits are interleavedLow — individual commits lost on main
git bisect effectivenessLower — merge commits are opaqueHigher — each commit is a bisect pointLowest — only one commit per feature
Conflict resolutionOnce at merge timeOnce per commit during rebaseOnce at merge time
Best forTeam integration, release branchesLocal branch cleanup before PRSimple integration, clean main history
Used byGitFlow, release-managed teamsIndividual developers before PRMany open-source projects, simple teams

🎯 Key Takeaways

  • Branches are cheap pointers to commits — create them freely. They cost nothing in Git.
  • Merge preserves commit SHAs and creates a DAG history. Rebase creates linear history but rewrites all commit SHAs.
  • Never rebase commits that have been pushed to a shared branch. It breaks every teammate's local history.
  • --no-ff forces a merge commit even when fast-forward is possible — useful for traceability and git log --first-parent.
  • git merge --abort cancels an in-progress merge and restores the pre-merge state. Use it when conflicts are too complex.
  • After resolving merge conflicts, always compile and test before committing. Conflict resolution is a common source of regression bugs.
  • Branch strategy should match release cadence: GitFlow for scheduled releases, GitHub Flow for continuous deployment, trunk-based for high-frequency integration.
  • Short-lived branches (hours, not days) minimize merge conflicts. Long-lived branches accumulate divergence and create integration pain.
  • Feature flags enable merging incomplete work to main safely — the feature is deployed but hidden until enabled.
  • git log --first-parent main shows only the integration timeline when using --no-ff merges — useful for release management and changelog generation.

⚠ Common Mistakes to Avoid

    Rebasing a pushed shared branch
    Symptom

    teammates' local histories diverge, 'Your branch and origin/<branch> have diverged' errors, orphaned feature branches —

    Fix

    never rebase branches others pull from. Use merge or fixup commits instead.

    Using --force instead of --force-with-lease after rebase
    Symptom

    silently destroys teammates' commits pushed after your last fetch —

    Fix

    always use --force-with-lease; configure alias for safety.

    Resolving merge conflicts without testing
    Symptom

    merge completes but the resolved code doesn't compile or tests fail —

    Fix

    always compile and run tests after resolving conflicts, before committing the merge.

    Creating long-lived feature branches that diverge significantly from main
    Symptom

    massive merge conflicts when finally integrating, weeks of work at risk —

    Fix

    rebase feature branch onto main daily, or merge main into feature branch regularly.

    Using fast-forward merges when traceability matters
    Symptom

    git log shows interleaved commits from multiple features with no grouping —

    Fix

    use --no-ff to create merge commits that group feature work.

    Not deleting merged branches
    Symptom

    git branch -a shows hundreds of stale branches, making it hard to find active work —

    Fix

    enable auto-delete after merge in GitHub/GitLab, or periodically prune with git remote prune origin.

    Committing conflict markers to the repository
    Symptom

    code has <<<<<<< HEAD markers committed, causing syntax errors at runtime —

    Fix

    always search for conflict markers before committing: grep -r '<<<<<<' src/.

Interview Questions on This Topic

  • QWhat is the difference between git merge and git rebase? When would you use each?
  • QWhat is a fast-forward merge? When does it happen and how does it differ from a 3-way merge?
  • QWhy should you never rebase a public branch? What exactly breaks when you do?
  • QExplain the --no-ff flag. Why would a team choose to always use it?
  • QWhat is a squash merge? What are its trade-offs vs a regular merge?
  • QHow would you resolve a complex merge conflict in a file modified by both branches?
  • QCompare GitFlow, GitHub Flow, and trunk-based development. When is each appropriate?
  • QWhat is git merge-base and how does Git use it during a 3-way merge?
  • QA teammate rebased develop and force-pushed. Your local develop has diverged. How do you recover?
  • QHow do feature flags relate to trunk-based development? Why are they necessary?

Frequently Asked Questions

When should I use merge vs rebase?

Use rebase to clean up your local feature branch before opening a PR — it keeps the PR diff clean and up-to-date with main. Use merge to integrate the PR into main — it preserves the fact that work happened in a branch. Many teams use squash merge to collapse a feature branch into one commit on main.

What is a fast-forward merge?

A fast-forward merge happens when the branch being merged has no diverging commits from the target — target's HEAD is an ancestor of the branch. Git just moves the target pointer forward rather than creating a merge commit. Use --no-ff to force a merge commit if you want to preserve the branch structure in history.

What is a squash merge and when should I use it?

Squash merge (git merge --squash) takes all commits from the source branch and stages them as a single change. You then create one commit on the target branch. This produces the cleanest main history — one commit per feature — but loses the individual commit history of the feature branch. Use it for simple features where the intermediate commits aren't valuable for debugging.

How do I prevent merge conflicts?

Merge conflicts are minimized by keeping branches short-lived and integrating frequently. Rebase your feature branch onto main daily (git rebase main). If a branch must live for weeks, merge main into it regularly (git merge main) to resolve conflicts incrementally. Good modular architecture also helps — if two branches rarely touch the same files, conflicts are rare.

What is trunk-based development?

Trunk-based development is a branching strategy where developers integrate to main (the trunk) very frequently — multiple times per day. Branches live for hours, not days. Incomplete work is hidden behind feature flags. This maximizes integration frequency, minimizes merge conflicts, and enables continuous deployment. It requires strong CI/CD pipelines and feature flag infrastructure.

What happens if I rebase a shared branch?

Rebasing rewrites all commit SHAs from the rebase point onward. If teammates have pulled the old SHAs, their local branches now reference commits that don't exist on the remote. Their next pull shows divergence. Feature branches based on old SHAs become orphaned. The recovery requires every teammate to fetch, inspect, and reset their local branches. This is why the golden rule exists: never rebase public commits.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

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