If your git history is full of merge commits that don’t seem to mean anything, you’re in good company. Git’s default behavior is actively working against you. The good news? A couple of configuration changes will fix it.
The problem with git pull
Here’s what happens when you run git pull with the default settings. You’re working on a branch with your teammates. You’ve got a few commits. Everything’s fine. Then you think, “I should grab the latest changes.” Reasonable! So you pull.
And git creates a merge commit.
You do this three times during the day because you want to stay up to date. Now you have three merge commits that say absolutely nothing except “I, a developer, updated my local copy of the repository.” Thanks for that vital historical information, git.
Your branch with default git pull:
main:
A───B───C───D───E───F
\ \ \ \
\ \ ├───M2──├───M3
\ \ │ │
\ ├──M1 │
\ │ │
yours──────yours'
M1 = "Merge branch 'main' into feature" (you pulled at 10am)
M2 = "Merge branch 'main' into feature" (you pulled at 2pm)
M3 = "Merge branch 'main' into feature" (you pulled at 5pm)
These commits don’t tell you anything about how the code evolved. They don’t show meaningful changes. They’re noise. Your git history now reads like a diary of your sync habits rather than a story of your codebase.
Here’s the thing: GitLab doesn’t even offer you the option to create a merge commit when updating your branch. When your merge request is behind the target branch, you get a “Rebase” button. That’s it. No merge commit option.
GitLab understood that there’s no value in a merge commit that only says “I got the latest code.” The history should show what actually happened to the code, not your personal workflow habits.
Your branch with rebase (what you want):
Before rebase:
main: A───B───C───D───E───F
\
X───Y (your commits)
After git pull --rebase:
main: A───B───C───D───E───F
\
X'──Y' (your commits, rebased)
No merge commits. Your commits sit on top of the latest main. The history tells a clean story.
When merge commits actually matter
Merge commits aren’t evil. They’re valuable when you’re merging a branch into main. That’s the moment when you want to show: “All of these commits were developed together, and they’re landing in main as a unit.”
The merge commit groups related work. When you’re debugging six months from now and you find a problematic line, you can trace it back to its merge commit and see the full context. That refactoring commit? It was part of a larger feature. That feature? Here’s every commit that went into it, all grouped together.
A meaningful merge commit:
main: A───B───C───────────────M───
\ /
feature: D───E───F───G
│ │
└───────────┘
"Add user authentication"
M = "Merge 'Add user authentication' into main"
Looking at M, you can see: commits D, E, F, G all belong together.
They implemented one feature. That's useful context.
This is useful information. “I pulled at 2:47 PM” is not.
The fast-forward trap
When you merge without the --no-ff flag, git will fast-forward if it can. Your branch commits get added directly on top of main, and it looks like they were developed there all along.
This erases context. You lose the information about which commits belonged together. The history looks linear, but it’s a lie. Those commits were developed on a branch, tested together, and merged as a unit. That information matters when you’re trying to understand why code exists.
Fast-forward merge (git merge feature):
Before:
main: A───B───C
\
feature: D───E───F
After:
main: A───B───C───D───E───F
│ │
└───────┘
Where did these come from?
Were they developed together?
Who knows! The context is gone.
No-fast-forward merge (git merge --no-ff feature):
Before:
main: A───B───C
\
feature: D───E───F
After:
main: A───B───C───────────M
\ /
D───E───F
M = merge commit that preserves the branch structure.
You can always see: D, E, F were developed together.
Use --no-ff when merging to main. Always.
The squash merge problem
Squash merging takes all the commits from your feature branch and combines them into a single commit on main. It sounds tidy. It’s actually throwing away information.
Squash merge (git merge --squash feature):
Before:
main: A───B───C
\
feature: D───E───F───G
│ │ │ │
│ │ │ └── "Fix edge case in auth"
│ │ └────── "Add login form"
│ └────────── "Set up auth service"
└────────────── "Add user model"
After:
main: A───B───C───S
S = "Add user authentication"
Where did commits D, E, F, G go? Gone.
Why was there an edge case fix? Who knows.
How did this feature evolve? Mystery.
When you squash, you lose the story of how the feature was built. That refactoring commit that seemed unrelated? It was preparing for the next step. That bug fix at the end? It reveals an edge case you might hit again. The individual commits show the developer’s reasoning, the problems they encountered, the decisions they made.
Six months from now, when you’re debugging and you find that the auth service is doing something unexpected, you won’t be able to trace back through the thought process. You’ll just see one big commit that says “Add user authentication” and wonder why things are the way they are.
The configuration you actually want
Here’s how to fix your git defaults:
git config --global pull.rebase true
Now git pull rebases by default instead of creating pointless merge commits. Your history stays clean. Your teammates will thank you. Or at least they won’t curse your name when reviewing the commit log.
For merges, get in the habit:
git merge --no-ff feature-branch
Or configure your merge strategy in your repository settings if your git hosting provider supports it.
The summary
- Configure git to rebase on pull:
git config --global pull.rebase true
- Use
--no-ff when merging branches: Preserve the context of grouped work
- Avoid squash merges: Keep the development history intact
A note on opinions
This is an opinionated piece, and reasonable people disagree on these things. Some teams love squash merges because they keep the main branch looking clean. Some developers prefer merge commits on pull because they mark a clear point in time. There are arguments for all of these approaches, and your team might have good reasons for doing things differently.
What matters most is that your team agrees on a workflow and sticks to it consistently. A consistent approach beats the “right” approach applied inconsistently.
That said, if you’re starting fresh or reconsidering your workflow, the approach outlined here optimizes for one thing: making your git history useful when you need to understand why code exists. Your future self, debugging at 11 PM, will appreciate a history that tells a story rather than a logbook of sync operations or a series of opaque squashed commits. Last modified on April 14, 2026