- Merge commits preserve which commits shipped together as a feature, making debugging and reverts straightforward—rebase destroys this grouping by interleaving unrelated work.
- Rebasing changes commit SHAs, breaking links to code review discussions and hiding conflict resolution decisions inside individual commits where they can't be audited.
- Force-push after rebase has a 56% chance of overwriting coworkers' work with 3 developers and 10-minute rebase windows—merge commits avoid this entirely.
- Use rebase for local cleanup before first push, merge with –no-ff for all shared branches to preserve forensic history.
A Linear History Broke Our Production Deploy
A forced rebase wiped three hours of debugging work from our staging branch. The culprit? A senior developer who’d internalized “always rebase, never merge” as gospel. We recovered the commits from reflog, but the incident sparked a question: why do we treat linear history as non-negotiable?
Most Git tutorials preach rebase for its clean history. But clean doesn’t mean correct. I’ve seen rebasing cause more problems than it solves—lost context in code review, botched conflict resolution that went unnoticed for days, and onboarding engineers who can’t tell which commits actually shipped together.
Here’s what happens when you test both approaches on the same messy feature branch.

The Rebase Promise vs Reality
Rebase rewrites history by replaying your commits on top of the target branch. The theory: you get a straight line of commits without merge bubbles.
git checkout feature-auth
git rebase main
The reality? Every commit gets a new SHA. If you’ve already pushed, you’re force-pushing:
git push --force-with-lease origin feature-auth
That --force-with-lease is supposed to be safer than --force, but I’ve watched it overwrite a coworker’s commits because their local reflog didn’t match the remote state. The error message was cryptic enough that they didn’t realize what happened until the next day.
Merge commits preserve the original history:
git checkout main
git merge --no-ff feature-auth
The --no-ff flag forces a merge commit even if fast-forward is possible. You get an explicit record that “these five commits were developed together as a feature.”
Case 1: Debugging Which Commits Actually Shipped
We deployed a performance regression in April. The suspect: changes to our Redis caching layer spread across 12 commits. With rebase history, those commits were interleaved with unrelated work:
* a3f891c Optimize cache key generation
* b2e47d1 Fix typo in README
* c9d102f Add TTL configuration
* d4a8e39 Update dependencies
* e7b293c Refactor cache invalidation
Which commits belong to the caching feature? You can’t tell without reading every diff. The rebased history destroyed the grouping.
With merge commits, the topology tells the story:
* f5c832a Merge branch 'feature/redis-caching'
|\
| * e7b293c Refactor cache invalidation
| * c9d102f Add TTL configuration
| * a3f891c Optimize cache key generation
|/
* d4a8e39 Update dependencies
Run git log --first-parent and you see only merge commits—a changelog of features, not individual code changes. To investigate the caching work, check out the merge commit’s second parent:
git log f5c832a^2
Every commit in that branch worked together. You can test them in isolation, revert the whole feature with one command, or cherry-pick the entire batch to a hotfix branch.
Rebase destroys this structure. I’m not entirely sure why we decided temporal ordering (when commits were rebased) is more valuable than logical grouping (which commits solve the same problem).
Case 2: Code Review Context Survives in Merges
Pull request reviews generate discussion—questions about edge cases, suggestions for refactoring, explanations of why you chose one approach over another. That context lives in GitHub/GitLab, attached to specific commit SHAs.
Rebase changes every SHA. The review comments now point to commits that don’t exist in your branch history.
I tested this with a Python decorator that caches API responses:
import functools
import time
from typing import Callable, Any
def ttl_cache(seconds: int = 300):
"""Cache function result with time-to-live.
Args:
seconds: Cache lifetime (default 5min)
Returns:
Decorated function with caching
"""
def decorator(func: Callable) -> Callable:
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# This shouldn't happen but handle kwargs for hashability
key = (args, tuple(sorted(kwargs.items())))
if key in cache:
result, timestamp = cache[key]
if time.time() - timestamp < seconds:
return result
result = func(*args, **kwargs)
cache[key] = (result, time.time())
return result
wrapper.cache_clear = cache.clear
return wrapper
return decorator
@ttl_cache(seconds=60)
def fetch_user_count(api_base: str) -> int:
"""Mock API call - in reality this hits an external service."""
import random
time.sleep(0.1) # Simulate network latency
return random.randint(1000, 2000)
if __name__ == "__main__":
print(f"First call: {fetch_user_count('https://api.example.com')}")
print(f"Cached call: {fetch_user_count('https://api.example.com')}")
In code review, someone asked: “Why tuple conversion for kwargs instead of using frozenset?” I explained that dictionary items maintain insertion order in Python 3.7+ and sorted() ensures consistent key generation even if the caller passes kwargs in different order.
After rebasing, that explanation is orphaned. The new commit SHA breaks the link between the question and the code. Six months later, another developer asks the exact same question because they can’t find the original discussion.
Merge commits keep the original SHAs. The review history stays intact.
Case 3: Conflict Resolution You Can Audit
Rebase resolves conflicts commit-by-commit. If you’re rebasing 20 commits and hit conflicts in commit 7, you fix them, continue, then hit different conflicts in commit 12.
The problem: each resolution is buried in that commit’s diff. There’s no record that says “this wasn’t the original code, I changed it during rebase.”
I tested this by rebasing a feature branch with intentional conflicts:
# Original feature branch
def calculate_discount(price: float, user_tier: str) -> float:
"""Apply tier-based discount."""
multiplier = {"bronze": 0.95, "silver": 0.90, "gold": 0.85}
return price * multiplier.get(user_tier, 1.0)
# Main branch (conflicting change)
def calculate_discount(price: float, user_level: int) -> float:
"""Apply level-based discount."""
discount_rate = 0.01 * user_level # 1% per level
return price * (1 - min(discount_rate, 0.15))
During rebase, I resolved this by keeping the tier-based approach but renaming the parameter to match main:
def calculate_discount(price: float, user_level: str) -> float:
"""Apply tier-based discount."""
multiplier = {"bronze": 0.95, "silver": 0.90, "gold": 0.85}
return price * multiplier.get(user_level, 1.0)
Look at the final diff:
git show a3f891c
It shows I added the calculate_discount function. No indication that I resolved a conflict, no hint that main had a competing implementation. If that resolution was wrong (maybe I should’ve kept the level-based logic), there’s no easy way to audit what I changed during rebase vs what was in the original commit.
With merge commits, conflicts are resolved once:
git merge feature-discount
# Fix conflicts
git add .
git commit -m "Merge feature-discount, resolved conflict in calculate_discount"
The merge commit’s diff shows exactly what changed:
git show f5c832a
You see both parent states and the resolution. The conflict wasn’t hidden inside individual feature commits—it’s explicit and reviewable. When something breaks, you check the merge commit first.

The Math Behind Force Push Disasters
Suppose developers work on a shared feature branch. Each developer pulls, writes commits, and pushes. If one developer rebases and force-pushes, the probability that at least one other developer’s work gets overwritten is:
where is the time window during which the rebase happens (between pull and force-push) and is the average time between pushes from other developers.
For a 10-minute rebase window () and developers pushing every 30 minutes on average (), with 3 developers:
Over half the time, someone’s work gets clobbered. Even --force-with-lease only protects against overwrites if the rebaser’s local tracking branch matches remote exactly—not guaranteed if they’ve been working offline or fetched without pulling.
Merge commits don’t have this problem. Concurrent work creates merge conflicts that Git detects and forces you to resolve explicitly.
When Rebase Actually Wins
I’m not saying never rebase. For local cleanup before pushing—squashing “fix typo” commits, reordering for clarity—interactive rebase is great:
git rebase -i HEAD~5
But that’s history you own, not shared history.
Rebase also works for personal feature branches with a single author. If you’re prototyping solo and want to keep your commits on top of main without merge noise, go ahead:
git pull --rebase origin main
Just don’t force-push to shared branches.
Merge Commits Aren’t Free
Merge-heavy histories get noisy. Run git log on a project with 50 feature branches merged per week and you’ll drown in “Merge branch ‘feature-foo’” commits.
The fix: use git log --first-parent or tools like git log --graph --oneline. Modern GUIs (GitKraken, Fork, even GitHub’s network graph) handle merge topology fine.
Another complaint: merge commits make bisect harder. Not really. git bisect understands first-parent traversal:
git bisect start --first-parent
It tests only merge commits, skipping the noise. Each test tells you which feature introduced the bug, then you bisect within that feature’s commits if needed.
The Tooling Excuse
Some teams enforce rebase because their CI/CD pipeline assumes linear history. That’s a tooling failure, not a Git best practice.
If your deployment script breaks on merge commits, fix the script. Don’t contort your workflow to accommodate bad automation. Here’s a deploy script that works with both:
#!/bin/bash
# deploy.sh - works with merge commits and rebased history
set -euo pipefail
# Get all commits since last deploy (stored in .last-deploy-sha)
LAST_DEPLOY=$(cat .last-deploy-sha || echo "origin/main~10")
# First-parent log shows only feature merges in merge-based workflow
# or individual commits in rebase-based workflow
git log --first-parent --pretty=format:"%H %s" "$LAST_DEPLOY"..HEAD | while read sha message; do
echo "Deploying: $message ($sha)"
# Your deploy logic here
done
# Update deploy marker
git rev-parse HEAD > .last-deploy-sha
If you need rebase to make your tools work, you need better tools. (Or maybe you need Working Effectively with Legacy Code to refactor that deployment pipeline—yes, build scripts count as legacy code.)
What I Actually Use
My rule: merge for shared branches, rebase for local cleanup.
Feature branches get merged with --no-ff to preserve topology. Before merging, I clean up my commits locally:
# On feature branch, squash "WIP" commits
git rebase -i origin/main
# Then merge into main
git checkout main
git merge --no-ff feature-auth
This gives me clean feature-level commits in main without losing the ability to trace features or revert them.
For hotfixes that touch one file, I’ll rebase onto the release branch to avoid a merge commit for something trivial. But that’s the exception.
FAQ
Q: Doesn’t rebase make git log easier to read?
Only if you never need to know which commits shipped together. Linear history optimizes for “what changed when” at the cost of “what was developed as a unit.” For debugging production issues, I care way more about the second question. Use git log --first-parent to see features without noise, or git log --graph to see the full topology. Both work fine with merge commits.
Q: How do I prevent force-pushes from breaking my team’s work?
Enable branch protection rules. In GitHub/GitLab, disallow force-push to shared branches (main, develop, release/*). For feature branches, set a team convention: rebase only before your first push. Once it’s on remote, merge or ask teammates before rebasing. We added a pre-push hook that blocks --force unless you set ALLOW_FORCE_PUSH=1 environment variable, which makes it a deliberate choice rather than autopilot.
Q: What about rebasing to pull in upstream changes while I work on a feature?
This is where I see the most accidents. git pull --rebase rewrites your local commits every time you sync with main. If you’ve pushed any of those commits, you’re force-pushing. Better: merge main into your feature branch (git merge origin/main). Yes, it creates a merge commit in your feature branch. So what? When you merge the feature back to main with --no-ff, that temporary merge commit becomes part of the feature’s history—it shows when you synced with upstream, which is actually useful context.
Pick the Tool That Preserves Information
The rebase-always dogma comes from a shallow reading of “clean history.” But history’s purpose isn’t aesthetic—it’s forensic. When a bug surfaces three months after deploy, you need to know which commits were related, what the reviewer questioned, how conflicts were resolved.
Merge commits preserve that information. Rebase erases it.
Use rebase for local tidying. Use merge for shared work. And if your team insists on rebasing everything, at least disable force-push on shared branches and document the conflict resolutions in commit messages.
I’d love to find a middle ground that gives rebase’s clean first-parent log without losing merge’s forensic value, but I haven’t seen a workflow that actually works at scale. If you’ve solved this, I’m curious what you’re doing—most teams I’ve talked to either went full-merge after a data loss incident or they’re still rebasing and just haven’t hit the disaster case yet.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,794 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (763 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (652 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (549 views)