Mert Özen Further With Every Line
DevOps Git & GitHub — #12

Understanding Git Pull: fetch, merge, rebase and Divergent Branches

Mert Özen Nov 6, 2026 9 min 2 views
Understanding Git Pull: fetch, merge, rebase and Divergent Branches

We see with real output that git pull is fetch plus merge or rebase, the "divergent branches" error on diverged branches since Git 2.33.1, the --no-rebase, --rebase and --ff-only options, the choice between pull.rebase and pull.ff, pull.autostash from Git 2.51 and fetch --prune.

Version note: The commands in this post were tested with Git 2.54; pull.autostash has been available since Git 2.51.

Guess First

At the end of the previous post Ada's push was rejected. Alan had written the test command into the README and pushed; Ada, unaware of this, had changed the price of Snow Crash and committed. Git suggested git pull to her. So that is what Ada does. This repo has no pull-related settings at all. What do you think happens: does Git create a merge commit, move Ada's commit on top of Alan's, or stop without doing anything?

Pull = Fetch + Integration

$ git pull
From ../bookstore
   3510c22..871320c  main       -> origin/main
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.

The answer is the third one: it stopped. But look at the first two lines of the output. Before stopping, Git did one job: a fetch. origin/main moved from 3510c22 to 871320c. git pull looks like a single command, but it is really two steps: first git fetch, then integrating what was downloaded into your own branch. The first step is always the same. Whether the second step is a merge or a rebase is your decision, and Git refuses to make that decision without asking you.

$ git status
On branch main
Your branch and 'origin/main' have diverged,
and have 1 and 1 different commits each, respectively.
  (use "git pull" if you want to integrate the remote branch with yours)

nothing to commit, working tree clean
$ git log --oneline --graph --all -4
* d76d45c Raise Snow Crash price
| * 871320c Document how to run tests
|/
| * dee3ed6 Add title search
|/
* 3510c22 Add Foundation

After the fetch the situation is clear: Ada's main and origin/main split from the same commit, and each has one commit of its own. It is the same situation as main and discount in the merge post; the only difference is that the other line now comes from another computer.

Why the "Divergent Branches" Error Exists

In the past, git pull would merge in this situation without asking anything. Sooner or later everyone found meaningless merge commits like Merge branch 'main' of ... in their history and could not tell where they came from. Git 2.27 first added a warning: if pull.rebase was not set, every pull suggested that you specify how to integrate. With Git 2.33.1 that warning became an error when the branches have diverged. The fatal: Need to specify how to reconcile divergent branches. line above is the result of that change.

The error only appears when the branches have diverged. If a fast-forward is possible, that is, if you have no local commits, pull works fine even without a setting. When the paths have split, Git would have to guess; it does not, and leaves the choice to you. There are three options, and the hint lines already list all three.

Option 1: Merge

$ git pull --no-rebase
From ../bookstore
   3510c22..871320c  main       -> origin/main
Merge made by the 'ort' strategy.
 README.md | 2 ++
 1 file changed, 2 insertions(+)
$ git log --oneline --graph -4
*   8fb3f33 Merge branch 'main' of ../bookstore
|\
| * 871320c Document how to run tests
* | d76d45c Raise Snow Crash price
|/
* 3510c22 Add Foundation

--no-rebase does pull's second step as a merge: fetch, then git merge origin/main. The result is a three-way merge and a merge commit. No commit's hash changes; the history stays as it was. The price is the small fork in the graph and a message like Merge branch 'main' of ../bookstore. This is not a merge that tells you a real feature was integrated; it only says two people committed to the same branch around the same time. As the team grows, merges like this make the history harder to read.

Option 2: Rebase

$ git pull --rebase
From ../bookstore
   3510c22..871320c  main       -> origin/main
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph -3
* 5e9fee1 Raise Snow Crash price
* 871320c Document how to run tests
* 3510c22 Add Foundation
$ git status -sb
## main...origin/main [ahead 1]
$ git push
To ../bookstore.git
   871320c..5e9fee1  main -> main

--rebase temporarily sets Ada's local commit aside, brings main to origin/main and re-applies Ada's commit on top of it. In a terminal you briefly see a Rebasing (1/1) line; when it finishes, the result line replaces it. The history became a straight line, there is no merge commit, and Ada can now push without trouble.

But look at the hash: Ada's commit was d76d45c, now it is 5e9fee1. Same content, different parent, so a new commit. Rebase does not move commits, it rewrites them. For local commits you have not pushed yet, this causes no problem at all. Rebasing commits that others have built on is a different matter; we will cover it in the rebase post together with the "golden rule".

Option 3: Fast-forward Only

$ git pull --ff-only
From ../bookstore
   3510c22..871320c  main       -> origin/main
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: 	git merge --no-ff
hint:
hint: or:
hint:
hint: 	git rebase
hint:
hint: Disable this message with "git config set advice.diverging false"
fatal: Not possible to fast-forward, aborting.

--ff-only says "do it only if you can fast-forward". Since the branches have diverged, it stopped and left the decision to you again. This is not a shortcoming, it is by design: it never makes an unexpected merge or rebase. When you have no local commits, it is a plain fast-forward. After Ada pushes, this is exactly what Alan's side looks like; Alan has no pull settings, and still nothing is asked:

# On Alan's computer
$ git pull
From .../bookstore
   871320c..5e9fee1  main       -> origin/main
Updating 871320c..5e9fee1
Fast-forward
 src/books.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Which One to Choose?

Instead of typing the option on every pull, you set it once. There are two common choices:

# Option A: always move local commits on top of the remote
$ git config set --global pull.rebase true
# Option B: fast-forward only, stop and decide when branches diverge
$ git config set --global pull.ff only

Option A, pull.rebase true, gives the least friction in daily work. Pull always works and the history stays straight. The price is that if a conflict appears during the rebase, you resolve it with a slightly different flow than a merge. It is a good default for teams that work on feature branches and merge into main through pull requests.

Option B, pull.ff only, is the safest. Git never merges or rebases on your behalf; when the branches diverge it stops, and you choose deliberately with git pull --rebase or git pull --no-rebase. It is ideal for people who want to keep the history under control at all times, and for automation. A deploy script running on a server using git pull --ff-only comes from the same logic.

pull.rebase false means explicitly choosing the old merge behaviour. The setting can also be given per branch, as in branch.main.rebase. On Git versions older than 2.46 you use the classic form git config --global pull.rebase true instead of git config set.

pull.autostash and fetch --prune

Going into a pull with uncommitted changes is another common situation. Alan has made a small edit in the README and has not committed it yet:

# On Alan's computer
$ git pull --rebase
error: cannot pull with rebase: You have unstaged changes.
error: Please commit or stash them.
$ git config set pull.autostash true
$ git pull --rebase
From .../bookstore
   871320c..5e9fee1  main       -> origin/main
Updating 871320c..5e9fee1
Created autostash: 36d6e05
Fast-forward
 src/books.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Applied autostash.
$ git status -s
 M README.md

pull.autostash arrived with Git 2.51 and applies only to git pull: it stashes your local changes before the pull and puts them back after the pull finishes. For pull, it overrides the rebase.autostash and merge.autostash settings. Keep in mind that putting the changes back can cause a conflict; we will look at that case separately in the stash post.

The last cleanup topic: branches deleted on the remote. Ada deleted the merged search branch from the remote. In Alan's repo, a remote-tracking branch that knows nothing about this is left behind:

$ git push origin --delete feature/search
To ../bookstore.git
 - [deleted]         feature/search
# On Alan's computer
$ git fetch
$ git branch -a
  feature/search
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/feature/search
  remotes/origin/main
$ git fetch --prune
From .../bookstore
 - [deleted]         (none)     -> origin/feature/search
$ git branch -vv
  feature/search dee3ed6 [origin/feature/search: gone] Add title search
* main           5e9fee1 [origin/main] Raise Snow Crash price

A plain git fetch downloads new things but does not delete the local copies of branches deleted on the remote. --prune does that. Alan's local feature/search branch is still there; the gone label says its upstream no longer exists. Deleting it is still Alan's decision. If you want a prune on every fetch, git config set --global fetch.prune true is enough.

Common Pitfalls

The first is copying the command from the hint without reading it. The git config pull.rebase false line is not "the right answer" just because it is first in the list; it brings back the old merge behaviour. Pick one of the three knowing what it does.

The second is running pull blindly. Pull does fetch and integration in one step and takes away your chance to look at what arrived. When you are not sure, run git fetch first, see what arrived with git log --oneline main..origin/main, then decide.

The third is panicking because the hash changed after a rebase, or the opposite, rebasing commits that are already pushed. It is normal for the hashes of local, unshared commits to change. Rewriting commits others already have, however, requires a --force push and breaks their work.

The fourth is typing git merge --abort during a conflict that appears in a rebase. If pull's second step is a rebase, the half-done work is a rebase too; to back out you need git rebase --abort, and to resolve and carry on git rebase --continue. git status always tells you which one you are in.

A Small Experiment

★☆☆ Rebuild Ada's situation with a bare repo and two clones: make one commit on main in each clone and push from one of them. In the other, with no settings, run git pull and read the error.

★★☆ Go forward twice from the same starting point: once with git pull --no-rebase and once with git pull --rebase. Compare the git log --oneline --graph output of the two results and the hash of the local commit.

★★★ Set pull.ff only, then on a diverged branch run first git pull and then git pull --rebase. Explain how the command-line option overrides the setting. Bonus: with pull.autostash on, what happens if the uncommitted change touches the same line as the incoming commit?

Ada and Alan can now work properly through the same repo, but our remote is still a local bare repo. In the next post we connect to the real GitHub: creating a repo, HTTPS and personal access tokens, credential helpers and authentication with SSH keys.