The previous lesson closed off local undoing: reset, restore, clean, and a decision table that ended with a row pointing here. This lesson deals with everything that happens between your repository and the server, and it closes the promise left open in lesson 04-05, where we saw the non-fast-forward rejection and only gave the basic way out.

The problem has a name and a precise definition: divergence. And it has a reputation for being complicated that it does not deserve, because as soon as you draw the graph the solution is usually obvious. What confuses people is that the same state presents itself with four different messages depending on how you discover it: a status that says "have diverged", a pull that refuses to decide, a rejected push, or a colleague reporting that commits have vanished on them.

We are going to see that all four are the same situation, that there are exactly three ways out of it, and that the choice between the three depends on a single question: whose branch is this?

And at the end, the serious case: what to do when the person who rewrote the published history was somebody else, and you have two days' work resting on a base that no longer exists.

Contents

  1. What "have diverged" means exactly
  2. Measuring the divergence
  3. The four messages for the same situation
  4. The three pull policies and which to choose
  5. How you end up with a divergence
  6. The rejected push: diagnosis and ways out
  7. --force-with-lease and why it sometimes does not protect you
  8. When the remote was rewritten and you have work on top of it
  9. Recovering the previous remote branch with origin/branch@{1}
  10. Unrelated histories: --allow-unrelated-histories
  11. Coordination: what needs saying and when

  1. What "have diverged" means exactly

Two branches have diverged when each one has commits the other does not have. Formally: a common ancestor exists, and two different paths lead away from it.

flowchart LR
    A["e91d4a8"] --> B["4f8a2e6<br/>(common ancestor)"]
    B --> C["b52c9d1"] --> D["7d3a8f4"]
    B --> E["9c4e7b2"] --> F["3e7f1a8"] --> G["8a1f6c3"]
    L(["main (local)"]) -.-> D
    R(["origin/main"]) -.-> G

Your main has two commits the server does not have. The server has three you do not have. The common ancestor is 4f8a2e6.

Compare it with the two cases that are not divergence:

Situation Graph What happens
Up to date Local and remote on the same commit Nothing to do
Behind The remote moved on, you did not git pull does a fast-forward: no problem
Ahead You moved on, the remote did not git push does a fast-forward: no problem
Diverged Both moved on from the ancestor A decision is needed

The key word is decide. A divergence is not an error: it is a situation in which Git cannot know what you want and refuses to invent it. Everything that follows is a way of making that decision.

Recalling lesson 03-01: the common ancestor is the one Git calculates with git merge-base, and it is the basis of the three-way merge. You can see it directly:

git merge-base main origin/main
git merge-base --all main origin/main     # if there are several candidates
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a

  1. Measuring the divergence

Before resolving, measure. These commands modify nothing.

# First thing, ALWAYS: fetch the real state of the server
git fetch origin

fetch updates origin/main without touching your main (lesson 04-04). Without this, you are reasoning about an old snapshot of the server.

The count

git rev-list --left-right --count main...origin/main
2	3

The three dots are essential: A...B is the symmetric difference, the commits that are on one branch but not on the other. --left-right separates them by side.

Output Meaning
0 0 Identical
0 N You are behind by N commits. pull is a fast-forward
N 0 You are ahead by N commits. push is a fast-forward
N M Diverged. A decision is needed

With the tracking branch configured (lesson 04-06), this is enough:

git rev-list --left-right --count @{u}...HEAD
git status -sb
## main...origin/main [ahead 2, behind 3]

Seeing what is on each side

This is what really lets you decide:

# My commits that the server does not have
git log --oneline main ^origin/main
# equivalent and more convenient:
git log --oneline origin/main..main
7d3a8f4 GT-241 styles for the pending indicator
b52c9d1 GT-241 calculate the counter over the complete list
# The server's that I do not have
git log --oneline main..origin/main
8a1f6c3 GT-238 fix focus after deleting
3e7f1a8 GT-244 document installation in the README
9c4e7b2 GT-244 add the start-up script
# Both sides at once, with a side marker
git log --oneline --left-right --graph main...origin/main
< 7d3a8f4 GT-241 styles for the pending indicator
< b52c9d1 GT-241 calculate the counter over the complete list
> 8a1f6c3 GT-238 fix focus after deleting
> 3e7f1a8 GT-244 document installation in the README
> 9c4e7b2 GT-244 add the start-up script

< is the left-hand side (your main), > the right-hand one (origin/main).

And the question that determines whether there are going to be conflicts:

# Do they touch the same files?
git diff --stat origin/main...main       # what I contribute
git diff --stat main...origin/main       # what they contribute

If the sets of files do not overlap, the integration will be clean.

An alias worth having

git config --global alias.div '!f() {
  git fetch -q ${1:-origin};
  echo "--- state ---"; git status -sb | head -1;
  echo "--- mine (not on the remote) ---"; git log --oneline @{u}..HEAD;
  echo "--- theirs (I do not have them) ---"; git log --oneline HEAD..@{u};
}; f'
git div

  1. The four messages for the same situation

Depending on how you arrive at the divergence, Git tells you about it in a different way. Recognising all of them is half the lesson.

A. In git status

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

This is the pure diagnosis. The numbers match rev-list --left-right --count.

B. In git pull with no configured policy

Since Git 2.27, pull refuses to choose for you:

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:
fatal: Need to specify how to reconcile divergent branches.

This is a good thing. Previously, pull performed a silent merge and filled the history with "Merge branch 'main' of git.example.com..." commits that nobody had asked for. Now it forces you to have a policy, and that is section 4.

C. In git pull with pull.ff only

fatal: Not possible to fast-forward, aborting.

It is the same information in fewer words: there is divergence and your policy says "do not decide for me". It is not a fault (lesson 04-04, error 3).

D. In git push

 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'git.example.com:team/task-manager.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.

Or, in its more alarming variant:

 ! [rejected]        main -> main (fetch first)

Both say the same thing: the server has commits you do not have, and accepting your push would leave them unreachable. It is a protection on the server's part, not a fault of yours. That is section 6.

  1. The three pull policies and which to choose

git pull is fetch + integration (lesson 04-04). The policy decides which integration.

Policy Configuration What it does Result
Merge pull.rebase false git merge origin/main A merge commit; history with forks
Rebase pull.rebase true git rebase origin/main Your commits are reapplied on top; linear history
Fast-forward only pull.ff only Aborts if there is divergence None: you decide, by hand

Visually, from the same starting point:

flowchart TD
    subgraph MERGE["pull.rebase false (merge)"]
        m1["4f8a2e6"] --> m2["b52c9d1 (mine)"] --> m3["7d3a8f4 (mine)"] --> mm["Merge"]
        m1 --> m4["9c4e7b2"] --> m5["3e7f1a8"] --> m6["8a1f6c3"] --> mm
    end
    subgraph REBASE["pull.rebase true"]
        r1["4f8a2e6"] --> r4["9c4e7b2"] --> r5["3e7f1a8"] --> r6["8a1f6c3"] --> r2["b52c9d1' (mine, new hash)"] --> r3["7d3a8f4' (mine, new hash)"]
    end

Which to choose, according to the workflow from module 7

Context Recommended policy Why
Your personal feature branch (GT-241) rebase It is yours, nobody else has it; the history stays clean for review
main in GitHub Flow / Trunk Based (07-04, 07-05) rebase or ff-only On main you should never have local commits; if you do, that is a warning sign
A genuinely shared branch (two people working at once) merge Rebasing a branch somebody else has means rewriting published history
develop in Git Flow (07-03) merge It is a long-lived, shared branch
When you are not sure ff-only It forces you to look before deciding. The most instructive option

The recommended configuration for most people:

# By default: do not decide for me
git config --global pull.ff only

# And on feature branches, an explicit rebase when appropriate
git pull --rebase

Or, if your team works with short branches and wants a linear history (which is what Ana does in task-manager):

git config --global pull.rebase true
git config --global rebase.autoStash true   # sets uncommitted changes aside and puts them back

rebase.autoStash avoids the "cannot pull with rebase: You have unstaged changes", which is the most frequent complaint against pull --rebase.

The golden rule still applies (lesson 05-01): pull --rebase rewrites your local commits, which you have not published yet. That is legitimate. What you must never do is rebase commits that are already on the server and that others may have.

And a setting that prevents a classic mistake:

git config --global pull.rebase.autoSquash true

If you had pending fixup! commits (lesson 05-02), they will not be squashed by surprise during a pull. Check it in your configuration: if you have it enabled without knowing, a pull --rebase can reorganise your commits.

  1. How you end up with a divergence

Understanding the cause matters, because it determines which of the ways out is the right one.

Cause 1: the normal, healthy one

Ana committed locally while Bruno was publishing his work. Nobody has done anything wrong. It is the normal behaviour of a distributed system with several people.

Way out: integrate (merge or rebase, according to the policy) and push. No drama.

Cause 2: an --amend after publishing

Carla published GT-247, spotted a typo in the message and ran git commit --amend.

git log --oneline -1
git push
b52c9d1 GT-247 show the number of pending tasks
 ! [rejected]        GT-247 -> GT-247 (non-fast-forward)

What has happened. --amend does not modify the commit: it creates a new one with a different hash and moves the branch (lesson 09-01, section 5.3). The original commit is still on the server. Local and remote now each have one commit the other does not have: a divergence of 1 and 1.

git rev-list --left-right --count GT-247...origin/GT-247
1	1

Way out: if the branch is hers alone, push --force-with-lease. If not, integration is needed, and the result is ugly (the commit would appear twice).

Cause 3: a reset followed by new work

Bruno ran git reset --hard HEAD~2 on a published branch to "get rid of two commits", and then worked for two more hours.

Local:   A → B → X → Y          (X, Y are the new work)
Remote:  A → B → C → D          (C, D are the commits he removed)

A divergence of 2 and 2. And here there is an underlying decision: should commits C and D disappear from the project or not?

Way out: if they should disappear and the branch is his, --force-with-lease. If the branch is shared, the correct answer was git revert (lesson 05-06) and not reset; now it is a case of integrating and reverting.

Cause 4: somebody rewrote the published history

Diego did a rebase -i on main to "tidy up the history" and forced the push. Everybody who had main fetched finds themselves with a divergence they did not cause.

Local:   A → B → C → D          (what you had, correct)
Remote:  A → B' → C' → D'       (the same changes, different hashes)

This is the serious case, and it has a section of its own: 8.

Cause 5: git pull on a branch two people are working on

Ana and Carla are working on GT-241 at the same time. Both run pull --rebase. Each rebase rewrites the other's commits. The result is a spiral of duplicated commits that ends badly.

Way out: on genuinely shared branches, pull.rebase false. The policy has to be per branch, not out of habit.

  1. The rejected push: diagnosis and ways out

This is the closing of what was promised in lesson 04-05.

The complete diagnosis, in four commands

# 1. The real snapshot of the server
git fetch origin

# 2. How far have I diverged?
git rev-list --left-right --count HEAD...@{u}
2	3
# 3. What is on the server that I do not have? Is it somebody else's?
git log --oneline --format='%h %an  %s' HEAD..@{u}
8a1f6c3 Bruno Salas  GT-238 fix focus after deleting
3e7f1a8 Bruno Salas  GT-244 document installation
9c4e7b2 Carla Vidal  GT-244 add the start-up script
# 4. And what have I got?
git log --oneline @{u}..HEAD

With those four answers you already know which way out applies.

The decision tree

flowchart TD
    Q1{"Are there commits on the remote<br/>that I do not have?"}
    Q1 -->|No| R0["It is not divergence:<br/>simply push"]
    Q1 -->|Yes| Q2{"Are they somebody else's,<br/>or legitimate commits of mine<br/>that I want to keep?"}
    Q2 -->|Yes| R1["INTEGRATE:<br/>pull --rebase (own branch)<br/>or pull --no-rebase (shared)"]
    Q2 -->|No: they are old versions<br/>of my own commits| Q3{"Does anybody else<br/>use this branch?"}
    Q3 -->|Yes| R2["INTEGRATE anyway,<br/>or coordinate before forcing"]
    Q3 -->|No, it is mine alone| R3["push --force-with-lease"]

Way out A: integrate and push again (the normal case)

git fetch origin
git rebase origin/main          # or: git merge origin/main
# ...resolve conflicts if there are any (lesson 03-05)...
git push

With pull configured:

git pull --rebase
git push

If conflicts appear during the rebase, the mechanics are those of lesson 05-01, including the inversion of ours/theirs. And if you get overwhelmed:

git rebase --abort      # you go back exactly to the previous state

Way out B: force, when the branch is yours

Only if all three conditions hold:

  1. It is a feature branch and nobody else uses it.
  2. What is on the server are old versions of your own commits.
  3. You have checked point 3 of the diagnosis and there are no commits from anybody else.
git push --force-with-lease
 + b52c9d1...7d3a8f4 GT-241 -> GT-241 (forced update)

The + at the front indicates that it was a forced push.

Never a bare --force. The difference is section 7.

Way out C: the one hardly anybody considers

Sometimes the right answer is not to force and not to integrate, but to publish somewhere else:

git push origin GT-241:GT-241-v2

Especially useful if the branch has an open pull request with review comments (lesson 07-02): forcing can leave the comments orphaned. Publishing a new branch keeps everything and allows comparison with git range-diff (lesson 07-02).

  1. --force-with-lease and why it sometimes does not protect you

Recalling lesson 04-05: --force-with-lease only forces if the server is where you think it is. It compares the real remote tip with your local copy of the tracking branch (refs/remotes/origin/GT-241).

git push --force-with-lease
 ! [rejected]        GT-241 -> GT-241 (stale info)
error: failed to push some refs

That stale info means: "the server is not where your origin/GT-241 says it is; somebody has published something since your last fetch". That is exactly the protection working: it has stopped you destroying somebody else's work.

The hole: the fetch that cancels the protection

And here is the detail hardly anybody knows about, and which has to be understood properly:

If you run git fetch just before --force-with-lease, the protection disappears.

Because fetch updates origin/GT-241 with what is on the server right now, including your colleague's new commits. Your lease comes to match reality, the check passes, and you force on top of work you have never seen.

# Dangerous sequence
git fetch                       # now origin/GT-241 includes Bruno's commit
git push --force-with-lease     # the check passes... and destroys Bruno's commit

The worst of it is that the sequence is reasonable: "I'll update before forcing" sounds like good practice. And many IDEs run fetch automatically in the background, so it can happen to you without you typing the command at all.

The solution: --force-if-includes

Git 2.30 added the option that fixes the hole:

git push --force-with-lease --force-if-includes

--force-if-includes additionally checks that the remote commits you are about to overwrite are incorporated into your local history, by looking at your reflog. That is to say: it requires you to have seen and integrated what you are about to replace, not merely to have fetched it.

Option What it checks Protects against your colleague
--force Nothing No
--force-with-lease That local origin/branch == server Yes, unless you have run fetch
--force-with-lease --force-if-includes Additionally, that you have integrated the remote material Yes

Configure it as the default behaviour:

git config --global push.useForceIfIncludes true

With that, every --force-with-lease you run carries the extra check automatically. It is one line of configuration that eliminates an entire class of accidents.

And the explicit variant, when you want to be absolutely precise:

# Force only if the remote tip is EXACTLY this hash
git push --force-with-lease=GT-241:b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b

Verbose, but there is no way to get it wrong.

  1. When the remote was rewritten and you have work on top of it

This is the serious case, and the one that produces the most panic. It deserves the complete procedure.

The situation. Diego did a rebase -i on main to tidy up the history and forced the push. Ana had main fetched and, on top of it, three GT-241 commits she had not yet published.

First of all: you have lost nothing. Your commits are still in your repository, intact. And you also have the old main commits locally, even though the server no longer has them. In fact, your clone is now the only copy of the previous version, and that puts you in a strong position.

git fetch origin
git status -sb
## GT-241...origin/GT-241 [ahead 3, behind 7]

Step 1: understand what has happened

git log --oneline --graph --all -20
* 9c4e7b2 (HEAD -> GT-241) GT-241 indicator styles
* 3e7f1a8 GT-241 calculate the counter
* 8a1f6c3 GT-241 filter groundwork
* 7d3a8f4 GT-238 fix focus after deleting        <- my old main
* b52c9d1 GT-244 document installation
* 4f8a2e6 chore: eslint configuration
| * 2f8c6e1 (origin/main) GT-238 fix focus after deleting   <- the new main
| * 7a4d9b3 GT-244 document installation
| * 5c1e8f2 chore: eslint configuration
|/
* 1e6f2c8 chore: initial commit

The messages are repeated on the two branches, with different hashes. That is the unmistakable pattern of a rewritten history. Confirm it:

git range-diff origin/main...7d3a8f4

git range-diff (lesson 07-02) compares two series of commits and says whether the content is the same:

1:  5c1e8f2 = 1:  4f8a2e6 chore: eslint configuration
2:  7a4d9b3 = 2:  b52c9d1 GT-244 document installation
3:  2f8c6e1 ! 3:  7d3a8f4 GT-238 fix focus after deleting
    @@ app.js
     - field.focus();
     + newTaskField.focus();

The first two are identical (=); the third changed (!) and it shows you exactly how. This is the information you need before deciding anything.

Step 2: the backup

git branch backup-GT-241
git branch backup-old-main 7d3a8f4

Two seconds. From here on, nothing you do is irreversible.

Step 3: transplant your commits with rebase --onto

The command that solves exactly this is the --onto from lesson 05-01. Its form is:

git rebase --onto <new-base> <old-base> <branch>

which reads: "take the commits on <branch> that come after <old-base> and reapply them on top of <new-base>".

In our case:

  • New base: origin/main (the new history).
  • Old base: 7d3a8f4 (the old main your branch hung off).
  • Branch: GT-241.
git rebase --onto origin/main 7d3a8f4 GT-241
Successfully rebased and updated refs/heads/GT-241.
git log --oneline --graph -8
* 4b8e2c9 (HEAD -> GT-241) GT-241 indicator styles
* 8f2d6a1 GT-241 calculate the counter
* 6e2b9c7 GT-241 filter groundwork
* 2f8c6e1 (origin/main) GT-238 fix focus after deleting
* 7a4d9b3 GT-244 document installation
* 5c1e8f2 chore: eslint configuration
* 1e6f2c8 chore: initial commit

Your three commits, with new hashes, on the new base. Not a single duplicated commit.

How to find the old base if you have not made a note of it:

# Your own branch's reflog says where it came from
git reflog show GT-241 | tail -5

# Or the common ancestor between your branch and the old main from the remote reflog
git merge-base GT-241 origin/main@{1}

Step 4: put the local main back where it belongs

git switch main
git status -sb
## main...origin/main [ahead 3, behind 3]

Your local main still has the old version. Since you had nothing of your own on main (all your work was on GT-241), simply align it:

git reset --hard origin/main

If you did have commits of your own on main, do not do that: take them out to a branch first (git branch my-main-commits) and transplant them with the same --onto.

Step 5: verify before pushing

git switch GT-241
git range-diff backup-GT-241...GT-241
1:  8a1f6c3 = 1:  6e2b9c7 GT-241 filter groundwork
2:  3e7f1a8 = 2:  8f2d6a1 GT-241 calculate the counter
3:  9c4e7b2 = 3:  4b8e2c9 GT-241 indicator styles

Three =s: the content of your commits is identical to what it was before the transplant. Nothing has been lost or altered. Now you can delete the backup with peace of mind:

git branch -D backup-GT-241 backup-old-main

And run the tests before publishing: the rebase has reapplied your code on a different base, and even if there were no conflicts, there can be semantic incompatibilities.

The case where your work WAS published

If your GT-241 commits were already on the server, after the rebase your branch diverges from origin/GT-241. Since GT-241 is yours:

git push --force-with-lease --force-if-includes

The procedure in summary

Step Command What for
1 git fetch origin The real snapshot
2 git log --graph --all + git range-diff Understand what they rewrote
3 git branch backup-<branch> Safety net
4 git rebase --onto origin/main <old-base> <branch> Transplant
5 git switch main && git reset --hard origin/main Align main
6 git range-diff backup-<branch>...<branch> Verify
7 Run the tests Verify properly
8 git push --force-with-lease --force-if-includes Publish (if applicable)

  1. Recovering the previous remote branch with origin/branch@{1}

A detail that saves situations and that hardly anybody knows about: remote tracking branches have a reflog too.

git reflog show origin/main
2f8c6e1 refs/remotes/origin/main@{0}: fetch: forced-update
7d3a8f4 refs/remotes/origin/main@{1}: fetch: fast-forward
b52c9d1 refs/remotes/origin/main@{2}: fetch: fast-forward

Two extremely valuable things in that output:

  1. forced-update is the proof that somebody rewrote the published history. If you were in doubt about what happened, there is the evidence with its date.
  2. origin/main@{1} is where the server's main was before the rewrite.
# See the previous history
git log --oneline origin/main@{1} -10

# Create a branch on it so you can work
git branch main-before-the-rebase origin/main@{1}

# Compare the two versions
git range-diff origin/main@{1}...origin/main

This is what makes it possible to reconstruct what the server had before, and it is the reason why, when somebody forces and destroys work, any colleague who had the branch fetched can put it back:

# Return the server's main to how it was (with permissions and coordination)
git push --force-with-lease origin main-before-the-rebase:main

And with dates too:

git log --oneline origin/main@{yesterday}
git log --oneline origin/main@{2.days.ago}

That is exactly the reflog mechanism, whose complete treatment — including the @{n} versus @{time} syntax and the expiry limits — is lesson 09-04.

  1. Unrelated histories: --allow-unrelated-histories

git pull origin main
fatal: refusing to merge unrelated histories

What it means. The two branches have no common ancestor at all. It is not a divergence: they are two completely independent family trees, each with its own root commit.

flowchart LR
    subgraph H1["Local history"]
        a1["1e6f2c8 (root)"] --> a2["4f8a2e6"] --> a3["b52c9d1"]
    end
    subgraph H2["Remote history"]
        b1["9a3c7d1 (root)"] --> b2["2f8c6e1"]
    end
git merge-base main origin/main
echo $?           # 1: there is no common ancestor

How you end up there

Cause Typical situation
You created the local repository before cloning git init + commits, and then you added an origin that already had an initial README
The server initialised the repository The platform created the repo with README.md and .gitignore, you had the project locally
Somebody rewrote the entire history git filter-repo (08-05) without --force, or a rewrite from the root
A push --force from the wrong repository Another project was published on top. This is an incident, not a normal case
Merging two projects on purpose Absorbing one repository into another

The way out

First of all, check that it is the benign case:

# The two roots
git log --oneline --max-parents=0 main
git log --oneline --max-parents=0 origin/main

# What is on the remote? If it is 47 commits from another project, STOP.
git log --oneline origin/main | head -20

If the remote only has the platform's initial commit:

git pull origin main --allow-unrelated-histories

It will probably conflict in README.md: resolve it as normal (lesson 03-05) and commit.

If the remote has the real project and your local history is the surplus one:

git branch my-local-history           # backup
git fetch origin
git reset --hard origin/main          # adopt the server's

And if somebody has published another project on top of yours, this is an incident: do not merge anything, tell the team, and use the origin/main@{1} from section 9 (or any colleague's clone) to restore. Lesson 09-05 covers the use of other clones as a backup.

Never run --allow-unrelated-histories "because Git is complaining". That refusal is a very useful sanity check: Git is telling you that those two things have nothing to do with each other. Find out why before you override it.

  1. Coordination: what needs saying and when

The non-technical part, which is the one that saves the most time.

Before rewriting anything shared:

  1. Give notice beforehand, not afterwards. A thirty-second message in the team channel: "I am going to force main at 16:00 to get the database dump out of the history. Do not pull until I say so."
  2. Say what everybody else has to do. Even if it is obvious to you, it is not to somebody who is busy with something else:
    After the all-clear, everybody:
      git fetch origin
      git switch main
      git reset --hard origin/main
    If you have branches with commits of your own, tell me before touching
    anything and we will do the rebase --onto together.
    
  3. Do it at a quiet time. Never on a Friday afternoon, never during a delivery.
  4. Confirm when it is over. Silence produces blind pulls.

When it has already happened and you gave no notice:

  1. Say so anyway, as soon as possible. The cost of saying "I have forced main, do not pull" after five minutes is zero. After five hours it is an afternoon's work for three people.
  2. Offer the procedure from section 8, not a "sort it out".
  3. If you have destroyed somebody else's work, section 9 recovers it from any clone in the team. It is not lost.

Structural prevention (lesson 07-06):

# On the server: protected branches
# - main: forced push forbidden, direct push forbidden
# - integration only via pull request

A protected branch makes this whole section unnecessary. If main does not accept --force, the problem cannot happen. It is the most profitable measure in the lesson.

And locally, a personal protection:

# A pre-push hook that prevents forcing onto main (lesson 06-01)
cat > .git/hooks/pre-push <<'EOF'
#!/usr/bin/env bash
# Blocks the forced push onto protected branches
protected="main develop"
while read -r local_ref local_sha remote_ref remote_sha; do
  branch="${remote_ref#refs/heads/}"
  for p in $protected; do
    if [ "$branch" = "$p" ] && [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then
      if ! git merge-base --is-ancestor "$remote_sha" "$local_sha"; then
        echo "BLOCKED: this would be a non-fast-forward push onto '$branch'." >&2
        echo "If it really is necessary, use --no-verify and tell the team." >&2
        exit 1
      fi
    fi
  done
done
exit 0
EOF
chmod +x .git/hooks/pre-push

Common Mistakes and Tips

Mistake 1: answering a rejected push with --force. The rejection means the server has commits you do not have. Forcing destroys them. Diagnose first: git log --oneline HEAD..@{u} says whose they are.

Mistake 2: reasoning about origin/main without having run fetch. origin/main is a local snapshot that may be hours old. Every diagnosis starts with git fetch.

Mistake 3: git fetch just before --force-with-lease. It cancels the protection. Use --force-if-includes, or configure it with push.useForceIfIncludes true.

Mistake 4: using pull --rebase on a branch somebody else also has. It rewrites published commits and generates duplicates in a chain. The policy must be per branch, not out of habit.

Mistake 5: running --allow-unrelated-histories without looking at what is on the other side. You can end up merging an entire other project into yours. Look at git log origin/main first.

Mistake 6: git reset --hard origin/main without checking what you have ahead. git log --oneline @{u}..HEAD costs a second and tells you exactly what you are about to throw away.

Mistake 7: redoing the work by hand after somebody else's rewrite. git rebase --onto transplants it in one command and range-diff proves nothing has been altered.

Mistake 8: forcing a branch with an open pull request full of comments. It can leave the review orphaned. Consider publishing a new branch.

Mistake 9: not telling anyone. The technical cost of a shared rewrite is small; the social cost of not announcing it, enormous.

Tip 1: git status -sb as a reflex. One line that says [ahead N, behind M] and settles half your doubts.

Tip 2: pull.ff only as the global value. It forces you to look before integrating, and avoids merge commits nobody asked for.

Tip 3: push.useForceIfIncludes true. One line of configuration that eliminates the --force-with-lease hole.

Tip 4: git range-diff before and after any rewrite. It is the objective proof that the content has not changed.

Tip 5: remember origin/branch@{1}. The server's previous history is still in your remote reflog, and with it you can restore what somebody else destroyed.

Tip 6: protect main on the server. It is the only solution that makes the problem impossible, rather than treating it.

Exercises

Exercise 1: provoking and measuring a divergence

  1. Set up a local remote with git init --bare and clone it twice (simulating Ana and Bruno).
  2. From Bruno's clone, make two commits and publish them.
  3. From Ana's, without running fetch, make two different commits.
  4. Run git fetch and measure the divergence with git status -sb, git rev-list --left-right --count and git log --left-right --oneline.
  5. Try git push and transcribe the exact message.
  6. Resolve it with pull --rebase and check with git log --graph that the history has ended up linear.
  7. Repeat the whole exercise with pull --no-rebase and compare the two resulting graphs.

Exercise 2: --force-with-lease and its hole

  1. With the same setup, have Ana publish a commit on GT-241.
  2. Ana runs git commit --amend on that commit (without publishing yet).
  3. In the meantime, Bruno publishes a new commit on GT-241.
  4. Ana runs git push --force-with-lease. Note the result and explain it.
  5. Ana runs git fetch and tries again. Note the result and explain why it has changed.
  6. Check that Bruno's commit has disappeared from the server and recover it using origin/GT-241@{1} from Bruno's clone.
  7. Repeat step 5 with --force-if-includes and check that it does protect this time.

Exercise 3: transplanting after somebody else's rewrite

  1. Set up a remote with three commits on main and clone it twice.
  2. From Ana's clone, create GT-241 on main with three commits of her own (without publishing it).
  3. From Diego's clone, do a git rebase -i on main to modify the second commit's message, and force the push.
  4. Ana runs fetch and diagnoses the situation with git log --graph --all and git range-diff.
  5. Ana transplants GT-241 with git rebase --onto and aligns her main.
  6. Verify with range-diff that her three commits are identical in content.
  7. Check in git reflog show origin/main that the forced-update has been recorded.

Solutions

Solution 1:

rm -rf /tmp/p9-03 && mkdir /tmp/p9-03 && cd /tmp/p9-03
git init -q --bare server.git

git clone -q server.git ana && cd ana
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"
echo "// task-manager" > app.js && git add . && git commit -q -m "chore: start"
git push -q -u origin main
cd ..
git clone -q server.git bruno && cd bruno
git config user.name "Bruno Salas"; git config user.email "bruno.salas@example.com"
cd ..
# 2. Bruno publishes
cd /tmp/p9-03/bruno
echo "// GT-238" >> app.js && git commit -q -am "GT-238 fix the focus"
echo "// GT-244" >> app.js && git commit -q -am "GT-244 start-up script"
git push -q
# 3. Ana works away without knowing
cd /tmp/p9-03/ana
echo "// GT-241 a" >> styles.css && git add . && git commit -q -m "GT-241 filter groundwork"
echo "// GT-241 b" >> styles.css && git commit -q -am "GT-241 indicator styles"
# 4. Measure
git fetch -q
git status -sb | head -1
git rev-list --left-right --count HEAD...@{u}
git log --oneline --left-right HEAD...@{u}
## main...origin/main [ahead 2, behind 2]
2	2
< 7d3a8f4 GT-241 indicator styles
< b52c9d1 GT-241 filter groundwork
> 3e7f1a8 GT-244 start-up script
> 9c4e7b2 GT-238 fix the focus
# 5. The rejection
git push
To /tmp/p9-03/server.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to '/tmp/p9-03/server.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.
# 6. Rebase
git pull -q --rebase
git log --oneline --graph -5
git push -q && echo "published"
* 4b8e2c9 (HEAD -> main) GT-241 indicator styles
* 8f2d6a1 GT-241 filter groundwork
* 3e7f1a8 (origin/main) GT-244 start-up script
* 9c4e7b2 GT-238 fix the focus
* 1e6f2c8 chore: start
published

Linear, no forks. Her two commits have new hashes (b52c9d18f2d6a1): the rebase has recreated them on the new base.

# 7. The alternative with merge (on a copy)
cd /tmp/p9-03/ana
git switch -q -c merge-test 8f2d6a1
git reset -q --hard b52c9d1        # we go back to the state before the rebase...
# (simpler: redo the scenario. What matters is the resulting graph:)
*   c7e2a91 Merge branch 'main' of /tmp/p9-03/server
|\
| * 3e7f1a8 GT-244 start-up script
| * 9c4e7b2 GT-238 fix the focus
* | 7d3a8f4 GT-241 indicator styles
* | b52c9d1 GT-241 filter groundwork
|/
* 1e6f2c8 chore: start
pull --rebase pull --no-rebase
Resulting commits 4 5 (one extra merge commit)
Ana's hashes Change Preserved
Shape of the graph Linear Forked
Good for Your own branches, a readable history Genuinely shared branches

Solution 2:

cd /tmp/p9-03/ana && git switch -q -c GT-241 origin/main
echo "// v1" > filter.js && git add . && git commit -q -m "GT-241 first version"
git push -q -u origin GT-241

# 2. Ana amends
git commit -q --amend -m "GT-241 first version of the pending filter"
git rev-parse --short HEAD
6e2b9c7
# 3. Bruno publishes on top (without Ana knowing)
cd /tmp/p9-03/bruno
git fetch -q && git switch -q -c GT-241 origin/GT-241
echo "// Bruno's contribution" >> filter.js && git commit -q -am "GT-241 validate the empty filter"
git push -q
# 4. Ana forces with a lease, WITHOUT a prior fetch
cd /tmp/p9-03/ana
git push --force-with-lease
To /tmp/p9-03/server.git
 ! [rejected]        GT-241 -> GT-241 (stale info)
error: failed to push some refs

The protection has worked. Ana's origin/GT-241 points at her original commit; the server is on Bruno's commit. Since they do not match, Git refuses.

# 5. Ana runs a fetch "to bring herself up to date" and tries again
git fetch -q
git push --force-with-lease
To /tmp/p9-03/server.git
 + b52c9d1...6e2b9c7 GT-241 -> GT-241 (forced update)

It went through. And it has destroyed Bruno's commit. The fetch updated origin/GT-241 to Bruno's commit; the lease came to match reality; the check was satisfied. Ana never saw Bruno's work and overwrote it all the same.

# 6. Recovering it from Bruno's clone
cd /tmp/p9-03/bruno
git reflog show origin/GT-241
6e2b9c7 refs/remotes/origin/GT-241@{0}: fetch: forced-update
b52c9d1 refs/remotes/origin/GT-241@{1}: push

In fact Bruno does not even need the reflog: he has the commit on his local branch. His local GT-241 is still intact:

git log --oneline -2
git branch bruno-rescue GT-241
2f8c6e1 GT-241 validate the empty filter
b52c9d1 GT-241 first version

And to put it back on the server it would need integrating with Ana's amended version — a cherry-pick of Bruno's commit onto the new branch (lesson 05-03) — coordinated between the two of them.

# 7. With --force-if-includes
cd /tmp/p9-03/ana
# (scenario redone: Bruno publishes something again that Ana does not have)
git fetch -q
git push --force-with-lease --force-if-includes
To /tmp/p9-03/server.git
 ! [rejected]        GT-241 -> GT-241 (stale info)

Now it does protect, even after the fetch. The extra check requires the remote commits that are about to be overwritten to be incorporated into Ana's local history, not merely fetched. And they are not: she never integrated them.

git config --global push.useForceIfIncludes true    # so you do not have to remember

Solution 3:

rm -rf /tmp/p9-03b && mkdir /tmp/p9-03b && cd /tmp/p9-03b
git init -q --bare server.git
git clone -q server.git ana && cd ana
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"
for i in 1 2 3; do echo "line $i" >> app.js; git add .; git commit -q -m "chore: commit $i"; done
git push -q -u origin main
cd .. && git clone -q server.git diego && cd diego
git config user.name "Diego Rueda"; git config user.email "diego.rueda@example.com"
cd ..
# 2. Ana creates GT-241
cd /tmp/p9-03b/ana
git switch -q -c GT-241
echo "// filter 1" > filter.js && git add . && git commit -q -m "GT-241 filter groundwork"
echo "// filter 2" >> filter.js && git commit -q -am "GT-241 calculate the counter"
echo "// filter 3" >> filter.js && git commit -q -am "GT-241 indicator styles"
git branch --show-current
git rev-parse --short main
GT-241
7d3a8f4

Note down that 7d3a8f4: it is the old base.

# 3. Diego rewrites main and forces
cd /tmp/p9-03b/diego
GIT_SEQUENCE_EDITOR="sed -i '2s/^pick/reword/'" \
GIT_EDITOR="sed -i '1s/.*/chore: commit 2 (corrected message)/'" \
  git rebase -i HEAD~2
git push -q --force
git log --oneline -3
2f8c6e1 chore: commit 3
7a4d9b3 chore: commit 2 (corrected message)
5c1e8f2 chore: commit 1
# 4. Ana diagnoses
cd /tmp/p9-03b/ana
git fetch -q
git log --oneline --graph --all -10
* 9c4e7b2 (HEAD -> GT-241) GT-241 indicator styles
* 3e7f1a8 GT-241 calculate the counter
* 8a1f6c3 GT-241 filter groundwork
* 7d3a8f4 (main) chore: commit 3
* b52c9d1 chore: commit 2
| * 2f8c6e1 (origin/main) chore: commit 3
| * 7a4d9b3 chore: commit 2 (corrected message)
|/
* 5c1e8f2 chore: commit 1

Two parallel lines with the same messages: rewritten history.

git range-diff origin/main...main
1:  7a4d9b3 ! 1:  b52c9d1 chore: commit 2 (corrected message)
    @@ Metadata
      ## Commit message ##
    -    chore: commit 2 (corrected message)
    +    chore: commit 2
2:  2f8c6e1 = 2:  7d3a8f4 chore: commit 3

Only one message changed; the content is identical. A complete diagnosis in one command.

# 5. Transplant
git branch backup-GT-241
git rebase --onto origin/main 7d3a8f4 GT-241
git log --oneline --graph -7
* 4b8e2c9 (HEAD -> GT-241) GT-241 indicator styles
* 8f2d6a1 GT-241 calculate the counter
* 6e2b9c7 GT-241 filter groundwork
* 2f8c6e1 (origin/main) chore: commit 3
* 7a4d9b3 chore: commit 2 (corrected message)
* 5c1e8f2 chore: commit 1
git switch -q main
git reset -q --hard origin/main
git switch -q GT-241
# 6. Verification
git range-diff backup-GT-241...GT-241
1:  8a1f6c3 = 1:  6e2b9c7 GT-241 filter groundwork
2:  3e7f1a8 = 2:  8f2d6a1 GT-241 calculate the counter
3:  9c4e7b2 = 3:  4b8e2c9 GT-241 indicator styles

Three =s. Identical content, new base, zero duplicated commits.

git diff backup-GT-241 GT-241 -- filter.js
(no output)
git branch -D backup-GT-241
# 7. The evidence in the remote reflog
git reflog show origin/main
2f8c6e1 refs/remotes/origin/main@{0}: fetch: forced-update
7d3a8f4 refs/remotes/origin/main@{1}: clone: from /tmp/p9-03b/server.git

forced-update with its date, and origin/main@{1} keeping the server's previous history. If it had been necessary to restore it, there it was.

Conclusion

Divergences stop being frightening as soon as you see them for what they are: a situation in which Git cannot decide for you.

  • Diverging means each branch has commits the other does not have. It is measured with git status -sb and with git rev-list --left-right --count main...origin/main, and it is understood by looking at both sides with git log --left-right. Every diagnosis starts with git fetch.
  • The four messages are the same situation: the "have diverged" from status, the "You have divergent branches" from pull, the "Not possible to fast-forward" from pull.ff only and the non-fast-forward from push.
  • Three pull policies: merge for genuinely shared branches, rebase for your own, ff-only when you prefer to decide by hand. The choice is per branch, not out of habit.
  • A rejected push is answered by diagnosing, not by forcing: git log --oneline HEAD..@{u} says whose the server's commits are. If they belong to somebody else, you integrate. If they are old versions of yours and the branch is yours alone, you force with --force-with-lease.
  • --force-with-lease does not protect you if you have run fetch just beforehand, because the lease is updated with what you have not yet seen. --force-if-includes fixes it; push.useForceIfIncludes true leaves it set for good.
  • When somebody else rewrites the published history and you have work on top of it, the procedure is: back up with git branch, transplant with git rebase --onto <new-base> <old-base> <branch>, align main with reset --hard origin/main, and verify with git range-diff that the content has not changed.
  • Remote branches have a reflog too. origin/main@{1} keeps where the server was before the rewrite, and forced-update is the evidence that it happened. With that, any clone in the team can restore what was destroyed.
  • refusing to merge unrelated histories is not divergence: they are two trees with no common ancestor. Look at what is on the other side before using --allow-unrelated-histories.
  • And what saves the most: giving notice beforehand, and protecting main on the server so that the problem cannot happen at all.

One idea remains that has appeared in almost every section without being developed: the reflog. It has been the source of the ORIG_HEAD from the previous lesson, of the origin/main@{1} from this one, and of the promise we have had outstanding since lesson 03-06 ("-D is recoverable") and 05-01 ("a disastrous rebase can be undone").

It is time to develop it in full: what it is exactly, where it lives, how long it lasts, how to read it, and the complete recovery recipe book — the reset --hard that swept away three days, the branch deleted with -D, the rebase that went wrong, the deleted stash — plus what to do when even the reflog is not enough.

Continue in lesson 09-04: Recovering Lost Commits.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved