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
- What "have diverged" means exactly
- Measuring the divergence
- The four messages for the same situation
- The three
pullpolicies and which to choose - How you end up with a divergence
- The rejected
push: diagnosis and ways out --force-with-leaseand why it sometimes does not protect you- When the remote was rewritten and you have work on top of it
- Recovering the previous remote branch with
origin/branch@{1} - Unrelated histories:
--allow-unrelated-histories - Coordination: what needs saying and when
- 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
- Measuring the divergence
Before resolving, measure. These commands modify nothing.
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
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:
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..main7d3a8f4 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
< 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 contributeIf 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'
- 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
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:
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.
- The three
pull policies and which to choose
pull policies and which to choosegit 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 --rebaseOr, 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 backrebase.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 --rebaserewrites 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:
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.
- 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.
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.
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.
- The rejected
push: diagnosis and ways out
push: diagnosis and ways outThis 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}# 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
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 pushWith pull configured:
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:
Way out B: force, when the branch is yours
Only if all three conditions hold:
- It is a feature branch and nobody else uses it.
- What is on the server are old versions of your own commits.
- You have checked point 3 of the diagnosis and there are no commits from anybody else.
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:
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).
--force-with-lease and why it sometimes does not protect you
--force-with-lease and why it sometimes does not protect youRecalling 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).
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 fetchjust 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 commitThe 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:
--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:
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:b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9bVerbose, but there is no way to get it wrong.
- 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.
Step 1: understand what has happened
* 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 (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
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:
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 oldmainyour branch hung off). - Branch:
GT-241.
* 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
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:
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
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:
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:
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) |
- Recovering the previous remote branch with
origin/branch@{1}
origin/branch@{1}A detail that saves situations and that hardly anybody knows about: remote tracking branches have a reflog too.
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-forwardTwo extremely valuable things in that output:
forced-updateis the proof that somebody rewrote the published history. If you were in doubt about what happened, there is the evidence with its date.origin/main@{1}is where the server'smainwas 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/mainThis 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:mainAnd with dates too:
That is exactly the reflog mechanism, whose complete treatment — including the @{n} versus @{time} syntax and the expiry limits — is lesson 09-04.
- Unrelated histories:
--allow-unrelated-histories
--allow-unrelated-historiesWhat 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
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 -20If the remote only has the platform's initial commit:
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'sAnd 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.
- Coordination: what needs saying and when
The non-technical part, which is the one that saves the most time.
Before rewriting anything shared:
- Give notice beforehand, not afterwards. A thirty-second message in the team channel: "I am going to force
mainat 16:00 to get the database dump out of the history. Do notpulluntil I say so." - 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. - Do it at a quiet time. Never on a Friday afternoon, never during a delivery.
- Confirm when it is over. Silence produces blind
pulls.
When it has already happened and you gave no notice:
- 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. - Offer the procedure from section 8, not a "sort it out".
- 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 requestA 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-pushCommon 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
- Set up a local remote with
git init --bareand clone it twice (simulating Ana and Bruno). - From Bruno's clone, make two commits and publish them.
- From Ana's, without running
fetch, make two different commits. - Run
git fetchand measure the divergence withgit status -sb,git rev-list --left-right --countandgit log --left-right --oneline. - Try
git pushand transcribe the exact message. - Resolve it with
pull --rebaseand check withgit log --graphthat the history has ended up linear. - Repeat the whole exercise with
pull --no-rebaseand compare the two resulting graphs.
Exercise 2: --force-with-lease and its hole
- With the same setup, have Ana publish a commit on
GT-241. - Ana runs
git commit --amendon that commit (without publishing yet). - In the meantime, Bruno publishes a new commit on
GT-241. - Ana runs
git push --force-with-lease. Note the result and explain it. - Ana runs
git fetchand tries again. Note the result and explain why it has changed. - Check that Bruno's commit has disappeared from the server and recover it using
origin/GT-241@{1}from Bruno's clone. - Repeat step 5 with
--force-if-includesand check that it does protect this time.
Exercise 3: transplanting after somebody else's rewrite
- Set up a remote with three commits on
mainand clone it twice. - From Ana's clone, create
GT-241onmainwith three commits of her own (without publishing it). - From Diego's clone, do a
git rebase -ionmainto modify the second commit's message, and force thepush. - Ana runs
fetchand diagnoses the situation withgit log --graph --allandgit range-diff. - Ana transplants
GT-241withgit rebase --ontoand aligns hermain. - Verify with
range-diffthat her three commits are identical in content. - Check in
git reflog show origin/mainthat theforced-updatehas 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 "[email protected]"
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 "[email protected]"
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}< 7d3a8f4 GT-241 indicator styles < b52c9d1 GT-241 filter groundwork > 3e7f1a8 GT-244 start-up script > 9c4e7b2 GT-238 fix the focus
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.
* 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
Linear, no forks. Her two commits have new hashes (b52c9d1 → 8f2d6a1): 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# 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 -qThe 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-leaseIt 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.
6e2b9c7 refs/remotes/origin/GT-241@{0}: fetch: forced-update
b52c9d1 refs/remotes/origin/GT-241@{1}: pushIn fact Bruno does not even need the reflog: he has the commit on his local branch. His local GT-241 is still intact:
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-includesNow 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.
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 "[email protected]"
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 "[email protected]"
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 mainNote 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* 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.
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 3Only 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
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.
2f8c6e1 refs/remotes/origin/main@{0}: fetch: forced-update
7d3a8f4 refs/remotes/origin/main@{1}: clone: from /tmp/p9-03b/server.gitforced-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 -sband withgit rev-list --left-right --count main...origin/main, and it is understood by looking at both sides withgit log --left-right. Every diagnosis starts withgit fetch. - The four messages are the same situation: the "have diverged" from
status, the "You have divergent branches" frompull, the "Not possible to fast-forward" frompull.ff onlyand the non-fast-forward frompush. - Three
pullpolicies:mergefor genuinely shared branches,rebasefor your own,ff-onlywhen you prefer to decide by hand. The choice is per branch, not out of habit. - A rejected
pushis 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-leasedoes not protect you if you have runfetchjust beforehand, because the lease is updated with what you have not yet seen.--force-if-includesfixes it;push.useForceIfIncludes trueleaves 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 withgit rebase --onto <new-base> <old-base> <branch>, alignmainwithreset --hard origin/main, and verify withgit range-diffthat the content has not changed. - Remote branches have a reflog too.
origin/main@{1}keeps where the server was before the rewrite, andforced-updateis the evidence that it happened. With that, any clone in the team can restore what was destroyed. refusing to merge unrelated historiesis 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
mainon 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
