Git & GitHub Module 5 – GitHub and remotes
Sync work with fetch, pull and push
How git fetch, git pull and git push differ, how to inspect incoming commits, fix a rejected push and replace --force with a lease that is really safe.
What you will learn
- Explain what git fetch changes, and inspect incoming commits before you integrate them
- Choose how git pull joins diverged branches, and act on the divergent-branches message
- Publish a branch with upstream tracking, delete a remote branch and prune stale names
- Recover from a rejected push without losing anyone's commits
- Use --force-with-lease with --force-if-includes instead of a bare --force
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what git fetch changes, and inspect incoming commits before you integrate them
- Choose how git pull joins diverged branches, and act on the divergent-branches message
- Publish a branch with upstream tracking, delete a remote branch and prune stale names
- Recover from a rejected push without losing anyone's commits
- Use --force-with-lease with --force-if-includes instead of a bare --force
Before you start
On this page
Ravi’s git push is refused: his classmate Asha pushed 1 commit to main first, and his repository has never seen
it. A forum answer tells him to add --force, which would drop Asha’s commit from main on the server. Three
commands move work between your repository and a remote, and each changes something different. git fetch downloads the
commits you do not have and moves only your remote-tracking branches, such as origin/main. git pull is a fetch
followed by one integration step into the branch you are on. git push uploads your commits and moves the branch on
the remote, by default only when no commit already there would be lost.
Every session below ran in Git 2.54.0, with a folder standing in for GitHub and two clones of it, Asha’s and Ravi’s. You will look at incoming work before it touches yours, choose how a pull joins diverged branches, publish and delete branches, get a refused push through safely, and rewrite your own pushed branch without overwriting anyone.
git fetch changes only your remote-tracking branches
Asha has pushed two commits. Ravi’s clone does not know yet: it still records the remote as it was at his last
fetch. Step through what one git fetch changes on his side, and what it leaves alone:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Asha pushes. Asha pushes two commits, Plan week 2 and Add the class rules. The shared repository's main moves from 7cd9f44 to 782c007.
- Ravi has not looked. Ravi's clone still records the remote as it was at his last fetch, origin/main at 7cd9f44, the same commit as his own main. git status cannot know about the new commits yet.
- git fetch. Ravi runs git fetch. His Git asks the remote where its branches point now, and downloads the commits it does not have.
- Only origin/main moves. The two commits arrive and origin/main moves to 782c007. Ravi's own main, his files and his staging area stay exactly as they were.
- Look before joining. git log HEAD..@{upstream} lists the two commits a pull would bring in, and git diff --stat HEAD...@{upstream} shows what they change. Nothing of Ravi's has moved yet.
- Integrate. git merge --ff-only @{upstream} moves main forward to origin/main, the step git pull --ff-only would take after its fetch. Both names now point at 782c007.
The fetch moved one name, origin/main, the remote-tracking branch: A reference such as origin/main that records where a branch of a remote pointed the last time your repository talked to that remote. It moves only when you fetch, pull or push, and you never commit to it. More in “Remotes and cloning: origin and tracking branches” that
records where the remote’s main was when Ravi last talked to it. His own main, his files and his staging area did
not change, so a fetch is safe to run at any moment, even in the middle of uncommitted work. Here is the same session
in real Git:
from session import run, setup
# Set-up: one shared repository and two clones of it, Asha's and Ravi's. Asha then pushes two commits.
setup("""
git init --bare -q origin.git
git clone -q origin.git asha
cd asha
git config user.name Asha
git config user.email asha@example.com
echo "Week 1: install Git" > plan.md
git add plan.md
git commit -q -m "Add the study plan"
git push -q origin main
cd ..
git clone -q origin.git ravi
cd asha
echo "Week 2: branches" >> plan.md
git commit -q -am "Plan week 2"
echo "Bring a laptop to every class" > rules.md
git add rules.md
git commit -q -m "Add the class rules"
git push -q origin main
cd ..
""")
run("""
cd ravi
git --version
git fetch
git status
# What the fetch brought: the commits on origin/main that main does not have yet.
git log --oneline HEAD..@{upstream}
git diff --stat HEAD...@{upstream}
# Nothing in Ravi's files has changed so far. Now he integrates:
git merge --ff-only @{upstream}
git log --oneline
""") Output
$ cd ravi
$ git --version
git version 2.54.0 (Apple Git-157)
$ git fetch
From ~/demo/origin
7cd9f44..782c007 main -> origin/main
$ git status
On branch main
Your branch is behind 'origin/main' by 2 commits, and can be fast-forwarded.
(use "git pull" to update your local branch)
nothing to commit, working tree clean
# What the fetch brought: the commits on origin/main that main does not have yet.
$ git log --oneline HEAD..@{upstream}
782c007 Add the class rules
df7d107 Plan week 2
$ git diff --stat HEAD...@{upstream}
plan.md | 1 +
rules.md | 1 +
2 files changed, 2 insertions(+)
# Nothing in Ravi's files has changed so far. Now he integrates:
$ git merge --ff-only @{upstream}
Updating 7cd9f44..782c007
Fast-forward
plan.md | 1 +
rules.md | 1 +
2 files changed, 2 insertions(+)
create mode 100644 rules.md
$ git log --oneline
782c007 Add the class rules
df7d107 Plan week 2
7cd9f44 Add the study plan
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fetch_session.py
git fetchprinted one line per remote-tracking branch it moved:origin/mainwent from 7cd9f44 to 782c007.git statusnow knew thatmainwas 2 commits behind and could be fast-forwarded. Before the fetch it could not know: its counts are only as fresh as the last fetch.@{upstream}(short form@{u}) means the upstream branch: The remote-tracking branch that a local branch follows, set by git clone, git push -u or git branch --set-upstream-to. git status compares the branch with it, and git pull and git push use it when given no arguments. More in “Remotes and cloning: origin and tracking branches” of the current branch, hereorigin/main.git log HEAD..@{upstream}lists the commits that are on the upstream but not onHEAD: exactly what an integration would bring in. The reverse,git log @{upstream}..HEAD, lists what a push would send.git diff --stat HEAD...@{upstream}, with three dots, shows what changed on the upstream since the point where the two histories split, without the changes that are only on your side.git merge --ff-only @{upstream}then movedmainforward toorigin/main, exactly the step thatgit pull --ff-onlywould have taken after its fetch.
The habit pays for itself the first time a teammate pushes something you did not expect: you see it while it is still
only on origin/main, where it changes nothing of yours.
Checkpoint
Check: What git fetch changes
git pull is a fetch plus one integration step
When your branch has no commits of its own, every pull is a Moving a branch forward to a commit that already contains its current commit. No merge commit is made and no commit is lost, because the old commit is part of the new history. and needs no decision.
When both sides have new commits, the branches have diverged branches: Two branches that each have commits the other lacks since their last common commit. Neither can fast-forward to the other, so joining them takes a merge or a rebase., and pull has to
know how you want them joined. The figure shows the diverged history of the rejected push later in this lesson, X with
Asha’s commit A on origin/main and Ravi’s commit B on main, and the three things that can happen next:
A diverged branch, and three ways out of it (merge, rebase, forced push)
Text description of the diagram
Four panels show the same history. The last three are alternatives, not steps.
- Diverged: commit X has two children. A is on origin/main (Asha's commit) and B is on main (Ravi's commit), so each side has a commit the other lacks.
- Merge, what git pull --no-rebase does: a new merge commit M has both A and B as its parents, and main points at M. No commit is rewritten.
- Rebase, what git pull --rebase does: Ravi's change is replayed on top of A as a new commit, B′, and main points at it. History is a single line, and B′ has a new ID.
- Forced push, what git push --force does: the remote's main is set to B. Commit A, drawn dashed and marked lost, is no longer on any branch of the remote. This is the outcome a rejected push protects you from.
A merge and a rebase are the two ways to join the histories; the forced push in the last panel is what a pull never does, and what the rest of this lesson keeps you from doing. Each of the three pull policies is a setting, or an option for one pull:
| Criterion | Fast-forward | Rebase | Merge |
|---|---|---|---|
| Setting | pull.ff only | pull.rebase true | pull.rebase false |
| One pull | --ff-only | --rebase | --no-rebase |
| Diverged | Stops | Replays your commits on theirs | Adds a merge commit |
| History | Same | One line, new IDs for yours | Two lines, joined |
| Rewrites | None | Unpushed commits | None |
| When to choose | Deciding each time | Short-lived branches | Keeping merge records |
Each policy has a cost. A rebase rewrites only the commits you have not pushed yet, which is usually safe, but test the replayed commits again, because they now sit on top of code they were never written against. A merge rewrites nothing, but a team that merges on every pull fills its history with commits named “Merge branch ‘main’ of …”. Fast-forward only never surprises you, and it hands every real decision back to you.
If you have set none of these, Git 2.54 pulls without a word as long as a fast-forward is possible, and stops with a
hint that lists all three settings as soon as the branches have diverged; the rejected-push session below prints that
hint. A pull that rebases also refuses to start while you have uncommitted changes, because, as the git config
documentation puts it, a rebasing pull “refuses to work with any local changes”. pull.autoStash puts them aside
first and brings them back afterwards.
One setting that never surprises you
Run git config --global pull.ff only once. Every pull that can fast-forward still just works, and a pull that would
have to join diverged branches stops, so you choose git pull --rebase or git pull --no-rebase knowing what it will
do.
Checkpoint
Check: Pull policies for diverged branches
The first push of a branch sets its upstream
A new branch exists only on your computer until you push it, and its first push decides where later plain pushes and pulls go:
from session import run, setup
# Set-up: Asha's clone of a shared repository with one commit, and Ravi's clone of the same repository.
setup("""
git init --bare -q origin.git
git clone -q origin.git asha
cd asha
echo "Week 1: install Git" > plan.md
git add plan.md
git commit -q -m "Add the study plan"
git push -q origin main
cd ..
git clone -q origin.git ravi
cd asha
""")
run("""
git switch -c quiz
echo "Q1: what does git fetch change?" > quiz.md
git add quiz.md
git commit -q -m "Start the quiz"
git push
git push -u origin quiz
git branch -vv
# With push.autoSetupRemote, the first plain git push of a new branch sets its upstream:
git config push.autoSetupRemote true
git switch -q -c answers
echo "A1: only remote-tracking branches" > answers.md
git add answers.md
git commit -q -m "Answer Q1"
git push
# Ravi fetches and sees both branches.
cd ../ravi
git fetch
# Later the quiz branch has been merged, and Asha deletes it on the server.
cd ../asha
git push origin --delete quiz
cd ../ravi
git fetch
git branch -r
git fetch --prune
git branch -r
""") Output
$ git switch -c quiz
Switched to a new branch 'quiz'
$ echo "Q1: what does git fetch change?" > quiz.md
$ git add quiz.md
$ git commit -q -m "Start the quiz"
$ git push
fatal: The current branch quiz has no upstream branch.
To push the current branch and set the remote as upstream, use
git push --set-upstream origin quiz
To have this happen automatically for branches without a tracking
upstream, see 'push.autoSetupRemote' in 'git help config'.
[exit status 128]
$ git push -u origin quiz
To ~/demo/origin.git
* [new branch] quiz -> quiz
branch 'quiz' set up to track 'origin/quiz'.
$ git branch -vv
main 60b930d [origin/main] Add the study plan
* quiz 5c021e8 [origin/quiz] Start the quiz
# With push.autoSetupRemote, the first plain git push of a new branch sets its upstream:
$ git config push.autoSetupRemote true
$ git switch -q -c answers
$ echo "A1: only remote-tracking branches" > answers.md
$ git add answers.md
$ git commit -q -m "Answer Q1"
$ git push
To ~/demo/origin.git
* [new branch] answers -> answers
branch 'answers' set up to track 'origin/answers'.
# Ravi fetches and sees both branches.
$ cd ../ravi
$ git fetch
From ~/demo/origin
* [new branch] answers -> origin/answers
* [new branch] quiz -> origin/quiz
# Later the quiz branch has been merged, and Asha deletes it on the server.
$ cd ../asha
$ git push origin --delete quiz
To ~/demo/origin.git
- [deleted] quiz
$ cd ../ravi
$ git fetch
$ git branch -r
origin/HEAD -> origin/main
origin/answers
origin/main
origin/quiz
$ git fetch --prune
From ~/demo/origin
- [deleted] (none) -> origin/quiz
$ git branch -r
origin/HEAD -> origin/main
origin/answers
origin/main
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 branch_session.py
- A plain
git pushof a new branch failed. With the default settingpush.default=simple, Git pushes the current branch to the branch of the same name on its upstream, and the new branchquizhad no upstream yet. The error names both fixes. git push -u origin quizcreated the branch on the remote and setorigin/quizas its upstream, so later pushes and pulls on it need no arguments;git branch -vvshows the upstream in brackets.push.autoSetupRemote=truemakes the first plaingit pushof any new branch do the same, as it did foranswers. Set it once withgit config --global push.autoSetupRemote true.git push origin --delete quizremoved the branch from the remote, for example after its pull request was merged.git fetchkeptorigin/quizalthough the branch had gone from the remote: Git deletes nothing unless told to.git fetch --pruneremoved it, andgit config --global fetch.prune truemakes every fetch prune.
Tags are not pushed by a plain git push; the tags and releases lesson of this track covers them.
A rejected push is protecting someone’s commit
Here Asha and Ravi both commit to main, and Asha pushes first. Step through Ravi’s pushes, what his Git learns from
the remote each time, and why it stops twice before one goes through:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Asha pushes first. Asha commits Plan week 2 and pushes. The remote's main moves from 7cd9f44 to her commit, e0db801, as a fast-forward.
- Ravi commits. Ravi has not fetched. He commits Add notes, 1323c7e, on top of 7cd9f44, the last commit he knows.
- Where is main now?. Ravi runs git push. His Git first reads where the remote's branches point, and main is at e0db801, a commit his repository has never seen.
- Refused, fetch first. Without that commit, Git cannot check that the push would keep it, so it stops with fetch first and sends nothing.
- git fetch. After git fetch, Ravi's Git has e0db801. git status counts 1 and 1 different commits on each side, so the branches have diverged.
- Refused again. Now Git can see that Ravi's main does not contain e0db801. Moving the remote's main to 1323c7e would drop Asha's commit, so the push stops as non-fast-forward.
- Rebase. git pull --rebase replays Add notes on top of Asha's commit as a new commit, c2c8d98. The history is one line again and holds both commits.
- Accepted. Now the push is a fast-forward from e0db801 to c2c8d98, and it goes through. Asha's commit and Ravi's are both on the remote's main.
A push moves the remote’s branch to your commit, and git push refuses any update that is not a fast-forward: your
commit must already contain the one the branch points at now, so no history is lost. Ravi’s first push stopped with
“(fetch first)”: the remote’s main pointed at a commit his repository had never seen, so Git could not even check.
After git fetch, the same push stopped with “(non-fast-forward)”: now Git had the commit, and could see that his
main did not contain it.
Forcing a refused push on a shared branch
git push --force after “(fetch first)” would also have “worked”: it moves the remote’s main to Ravi’s commit, and
Asha’s commit drops off the branch for everyone, as the last panel of the diverged figure shows. Fetch, rebase or
merge, test, then push without any force.
Here is the whole session, the pulls included:
from session import run, setup
# Set-up: Asha and Ravi share one repository; both start from the same commit.
setup("""
git init --bare -q origin.git
git clone -q origin.git asha
cd asha
git config user.name Asha
git config user.email asha@example.com
echo "Week 1: install Git" > plan.md
git add plan.md
git commit -q -m "Add the study plan"
git push -q origin main
cd ..
git clone -q origin.git ravi
cd ravi
git config user.name Ravi
git config user.email ravi@example.com
cd ..
""")
run("""
# Asha pushes first.
cd asha
echo "Week 2: branches" >> plan.md
git commit -q -am "Plan week 2"
git push -q
# Ravi committed a different file, without fetching.
cd ../ravi
echo "Bring a laptop" > notes.md
git add notes.md
git commit -q -m "Add notes"
git push
git fetch
git status
git push
git log --oneline --graph --all
git pull
git config pull.ff only
git pull
git pull --rebase
git log --oneline --graph --all
git push
""") Output
# Asha pushes first. $ cd asha $ echo "Week 2: branches" >> plan.md $ git commit -q -am "Plan week 2" $ git push -q # Ravi committed a different file, without fetching. $ cd ../ravi $ echo "Bring a laptop" > notes.md $ git add notes.md $ git commit -q -m "Add notes" $ git push To ~/demo/origin.git ! [rejected] main -> main (fetch first) error: failed to push some refs to '~/demo/origin.git' hint: Updates were rejected because the remote contains work that you do not hint: have locally. This is usually caused by another repository pushing to hint: the same ref. If you want to integrate the remote changes, use hint: 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. [exit status 1] $ git fetch From ~/demo/origin 7cd9f44..e0db801 main -> origin/main $ 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 push To ~/demo/origin.git ! [rejected] main -> main (non-fast-forward) error: failed to push some refs to '~/demo/origin.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. If you want to integrate the remote changes, hint: use 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. [exit status 1] $ git log --oneline --graph --all * 1323c7e Add notes | * e0db801 Plan week 2 |/ * 7cd9f44 Add the study plan $ git pull 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. [exit status 128] $ git config pull.ff only $ git pull 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. [exit status 128] $ git pull --rebase Rebasing (1/1) Successfully rebased and updated refs/heads/main. $ git log --oneline --graph --all * c2c8d98 Add notes * e0db801 Plan week 2 * 7cd9f44 Add the study plan $ git push To ~/demo/origin.git e0db801..c2c8d98 main -> main
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rejected_session.py
git status explained the situation in numbers: the branches had diverged, with 1 different commit on each side.
Without a policy, git pull stopped and listed the three settings. With pull.ff only it refused again, because no
fast-forward was possible. git pull --rebase replayed Ravi’s commit “Add notes” on top of Asha’s, which gave it a new
ID, c2c8d98, and the graph became one line. Only then did git push go through, as a plain fast-forward. The recipe
is always the same: fetch, integrate, test, push. If someone pushes again while you test, the next push is refused
again, and you run the same four steps once more.
Key idea
A push is refused only when it would drop commits that are already on the remote: bring them into your branch
first, test, and push again, never with --force on a branch other people commit to.
Checkpoint
Check: Publishing and deleting branches · Getting a rejected push through
A lease makes a forced push conditional
Sometimes rewriting what you pushed is right: you fixed a commit message with git commit --amend, or moved your own
feature branch onto a newer main with git rebase. The remote then holds the old commits, your branch holds their
replacements, and only a forced push can swap them. --force replaces whatever the branch holds, including commits
someone added since you last looked. --force-with-lease: A git push option that forces the update only if the remote branch still points where your remote-tracking branch says it does, so it refuses to overwrite commits pushed since your last fetch. adds a condition: the
remote branch must still point where your remote-tracking branch says it does. Step through a lease that works once
and is then defeated by a fetch nobody noticed:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Ravi adds a test. Ravi pushes Search, add a test to the shared search branch. The remote's search moves from Asha's first draft, 154b934, to cf2f0ba.
- Asha rewrites. Asha has not fetched. She amends her commit into cf7a26e, and her origin/search still says 154b934, the draft she pushed.
- The lease is checked. git push --force-with-lease replaces the remote's search only if it still points at 154b934, where Asha's origin/search says it is. Her Git first reads where it points now.
- Refused, stale info. cf2f0ba is not 154b934, so the push stops with stale info and sends nothing. The lease has saved Ravi's test.
- A background fetch. Asha's editor runs git fetch on its own, as many editors and prompt scripts do. Her origin/search becomes cf2f0ba, and she never looks at it.
- Accepted, test dropped. The same command now matches the lease, and the remote's search is replaced by cf7a26e. Ravi's test is no longer on the branch, and nothing warned anyone.
- The second condition. --force-if-includes adds a check the background fetch cannot pass. The commit about to be overwritten must once have been part of Asha's own branch, and cf2f0ba never was.
In the session, Ravi has added a test to Asha’s search branch, and Asha, who has not fetched since, amends her
commit:
from session import run, setup
# Set-up: Asha's feature branch is on the shared repository, Ravi has added a test to it, and Asha, who has not
# fetched since, rewrites her commit with --amend.
setup("""
git init --bare -q origin.git
git clone -q origin.git asha
cd asha
git config user.name Asha
git config user.email asha@example.com
echo "v1" > app.md
git add app.md
git commit -q -m "Start the app"
git push -q origin main
git switch -q -c search
echo "search: first draft" > search.md
git add search.md
git commit -q -m "Search: first draft"
git push -q -u origin search
cd ..
git clone -q origin.git ravi
cd ravi
git config user.name Ravi
git config user.email ravi@example.com
git switch -q search
echo "test: search finds one result" > search_test.md
git add search_test.md
git commit -q -m "Search: add a test"
git push -q
cd ../asha
echo "search: first version" > search.md
git commit -q -a --amend -m "Search: first version"
""")
run("""
git log --oneline --graph --all
# Asha's origin/search still points at her first draft, but the branch on the server has moved on:
git push --force-with-lease
# An editor or a prompt script fetches in the background, and the lease is renewed without Asha seeing it:
git fetch -q
git push --force-with-lease
# Ravi's test is no longer on the branch:
git log --oneline origin/search
""") Output
$ git log --oneline --graph --all * cf7a26e Search: first version | * 154b934 Search: first draft |/ * e9014a6 Start the app # Asha's origin/search still points at her first draft, but the branch on the server has moved on: $ git push --force-with-lease To ~/demo/origin.git ! [rejected] search -> search (stale info) error: failed to push some refs to '~/demo/origin.git' [exit status 1] # An editor or a prompt script fetches in the background, and the lease is renewed without Asha seeing it: $ git fetch -q $ git push --force-with-lease To ~/demo/origin.git + cf2f0ba...cf7a26e search -> search (forced update) # Ravi's test is no longer on the branch: $ git log --oneline origin/search cf7a26e Search: first version e9014a6 Start the app
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lease_session.py
The first --force-with-lease was refused with “(stale info)”: Asha’s origin/search still pointed at her first
draft, 154b934, but the server’s branch had moved on to Ravi’s test. That refusal saved his commit. Then git fetch -q
ran, the way an editor or a shell prompt script fetches every few minutes without telling anyone, and renewed the
lease. The same command was accepted, and the last line shows that “Search: add a test” was gone from the branch. The
git push documentation warns about exactly this: a lease without an expected value “interacts very badly” with
anything that runs git fetch in the background.
--force-if-includes, added in Git 2.30, closes that gap. Together with --force-with-lease, it also checks that the
tip of your remote-tracking branch can be reached from an entry of your local branch’s reflog: that the commit you are
about to overwrite was at some point part of your own branch, so you have actually seen it. Without a lease it does
nothing. In this session a background fetch has already brought in Ravi’s test and a new commit on main, and Asha
rebases onto main without looking:
from session import run, setup
# Set-up: Asha's search branch is on the shared repository, Ravi has added a test to it, main has a new commit, and
# a background fetch has already brought all of that into Asha's remote-tracking branches.
setup("""
git init --bare -q origin.git
git clone -q origin.git asha
cd asha
git config user.name Asha
git config user.email asha@example.com
echo "v1" > app.md
git add app.md
git commit -q -m "Start the app"
git push -q origin main
git switch -q -c search
echo "search: first draft" > search.md
git add search.md
git commit -q -m "Search: first draft"
git push -q -u origin search
cd ..
git clone -q origin.git ravi
cd ravi
git config user.name Ravi
git config user.email ravi@example.com
git switch -q search
echo "test: search finds one result" > search_test.md
git add search_test.md
git commit -q -m "Search: add a test"
git push -q
git switch -q main
echo "v2" > app.md
git commit -q -am "App: version 2"
git push -q
cd ../asha
git fetch -q
""")
run("""
# Asha moves her branch onto the new main, then force-pushes with both checks:
git rebase -q origin/main
git push --force-with-lease --force-if-includes
# Her rebased copy holds nothing new, so she starts again from the branch as the server has it:
git switch -q -C search origin/search
git rebase -q origin/main
git log --oneline --graph search
git push --force-with-lease --force-if-includes
git log --oneline origin/search
""") Output
# Asha moves her branch onto the new main, then force-pushes with both checks: $ git rebase -q origin/main $ git push --force-with-lease --force-if-includes To ~/demo/origin.git ! [rejected] search -> search (remote ref updated since checkout) error: failed to push some refs to '~/demo/origin.git' hint: Updates were rejected because the tip of the remote-tracking branch has hint: been updated since the last checkout. If you want to integrate the hint: remote changes, use 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. [exit status 1] # Her rebased copy holds nothing new, so she starts again from the branch as the server has it: $ git switch -q -C search origin/search $ git rebase -q origin/main $ git log --oneline --graph search * 0a7775c Search: add a test * eefbac9 Search: first draft * 4c4115a App: version 2 * e9014a6 Start the app $ git push --force-with-lease --force-if-includes To ~/demo/origin.git + cf2f0ba...0a7775c search -> search (forced update) $ git log --oneline origin/search 0a7775c Search: add a test eefbac9 Search: first draft 4c4115a App: version 2 e9014a6 Start the app
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 includes_session.py
The lease alone would have passed, because the background fetch had updated origin/search. With
--force-if-includes the push was refused with “(remote ref updated since checkout)”, because Ravi’s commit had
never been part of Asha’s branch. Her rebased copy held nothing that was not already on the server, so she started
again from the server’s branch with git switch -C search origin/search, rebased that onto main and pushed. The log
shows both commits, hers and Ravi’s, on top of the new main.
To make the check the default, run git config --global push.useForceIfIncludes true; after that,
git push --force-with-lease checks both conditions. Keep forced pushes for branches that are yours, and tell anyone
who shares one before you rewrite it.
Checkpoint
Check: Leases and --force-if-includes
Practise: count ahead and behind
git status decides “behind”, “ahead” or “diverged” by counting commits on each side. Write the same count for a
commit graph of your own, then the advice that follows from it; the sample tests try a straight line, a fork, a merge
and a history of 2,000 commits:
Exercise · Medium · Python
Count ahead and behind, then say what to run next
git status compares a branch with its upstream by counting commits. Do the same in sync.py, on a commit graph given as a dictionary from each commit's ID to the list of its parents' IDs (a first commit has none, a merge commit has two).
ahead_behind(parents, local, upstream) returns a pair (ahead, behind): how many commits can be reached from local but not from upstream, and how many can be reached from upstream but not from local. A commit can be reached from itself and from every commit that has it as a parent, a grandparent and so on.
next_step(ahead, behind) says what to do next, as one of four strings:
"nothing"when both counts are 0;"push"when the branch is only ahead;"pull"when it is only behind (a pull is then a fast-forward);"integrate, then push"when it is both, which means the two have diverged.
With parents = {"a": [], "b": ["a"], "c": ["b"], "x": ["b"]}, ahead_behind(parents, "c", "x") is (1, 1) and next_step(1, 1) is "integrate, then push". The sample tests also try a merge commit and longer histories.
Starter code · sync.py
def ahead_behind(parents, local, upstream):
"""Return (ahead, behind): commits only `local` can reach, and commits only `upstream` can reach."""
# Replace this line with your code.
return (0, 0)
def next_step(ahead, behind):
"""Return "nothing", "push", "pull" or "integrate, then push"."""
# Replace this line with your code.
return "nothing" The sample tests · test_sync.py
from sync import ahead_behind, next_step
LINE = {"a": [], "b": ["a"], "c": ["b"], "d": ["c"]}
FORK = {"a": [], "b": ["a"], "c": ["b"], "x": ["b"], "y": ["x"]}
# m merges y into c: the local branch at m has integrated the upstream branch at y.
MERGED = {"a": [], "b": ["a"], "c": ["b"], "x": ["b"], "y": ["x"], "m": ["c", "y"]}
def test_same_commit():
"""finds nothing to do on the same commit"""
assert ahead_behind(LINE, "d", "d") == (0, 0)
assert next_step(0, 0) == "nothing"
def test_only_ahead_or_only_behind():
"""counts a straight line both ways"""
assert ahead_behind(LINE, "d", "b") == (2, 0)
assert ahead_behind(LINE, "b", "d") == (0, 2)
assert next_step(2, 0) == "push"
assert next_step(0, 2) == "pull"
def test_diverged():
"""counts the commits on each side of a fork"""
assert ahead_behind(FORK, "c", "y") == (1, 2)
assert ahead_behind(FORK, "y", "c") == (2, 1)
assert next_step(1, 2) == "integrate, then push"
def test_after_a_merge():
"""counts the merge commit and the local commit as ahead, and nothing as behind"""
assert ahead_behind(MERGED, "m", "y") == (2, 0)
assert ahead_behind(MERGED, "y", "m") == (0, 2)
def test_long_history():
"""stays correct on a history of 2,000 commits that splits after 1,000"""
parents = {"0": []}
for i in range(1, 1000):
parents[str(i)] = [str(i - 1)]
for side in ("L", "U"):
previous = "999"
for i in range(1000, 1500 if side == "L" else 2000):
parents[f"{side}{i}"] = [previous]
previous = f"{side}{i}"
assert ahead_behind(parents, "L1499", "U1999") == (500, 1000) A hint
Write a helper that collects every commit reachable from one commit: start a list with that commit, and while the list is not empty, take a commit off it, skip it if you have seen it, and otherwise add it to a set and put its parents on the list. Then ahead is the size of one set minus the other, and behind the other way round.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To replay any session of this page on your own computer, put its file next to this helper and run it with Python, for
example python3 lease_session.py. The helper makes the remote and both clones in a throwaway folder, fixes every
name and time so the commit IDs come out as shown here, and deletes everything at the end:
"""Shows a terminal session with Git: each line of a script is typed at a prompt (after "$"), and what the command
printed appears under it.
Every session starts in a new, empty folder, shown as ~/demo, with a home folder of its own, so your own Git settings
take no part: its ~/.gitconfig holds only a name, an email address and init.defaultBranch = main. Standard output and
standard error appear together, as in a terminal, and a command that fails adds a line with its exit status, after
which the session goes on. "cd <folder>" changes the folder of the commands that follow, as in a shell; a line that
starts with "#" is printed as a comment. Nothing uses the network: the "remote" repositories are folders.
So that every run prints the same text:
* each command runs one minute after the one before (GIT_AUTHOR_DATE and GIT_COMMITTER_DATE), so the commit IDs
are the same on every run (yours will differ, because your commits carry your own times);
* the session's folder is shown as ~/demo and its home folder as ~, and spaces at the ends of lines are dropped;
* mask= rewrites text that is new on every run, such as a freshly made key, as a description of it.
On a Mac, the Command Line Tools' own git is used directly when it is there (the /usr/bin/git launcher in front of it
can print cache warnings when it runs without a writable home folder).
"""
import atexit
import os
import re
import shlex
import shutil
import subprocess
import tempfile
# The example's own folder (files() copies from it) and the session's folders, which are removed at the end.
HERE = os.getcwd()
_BASE = os.path.realpath(tempfile.mkdtemp(prefix="git-session-"))
atexit.register(shutil.rmtree, _BASE, ignore_errors=True)
ROOT = os.path.join(_BASE, "demo")
HOME = os.path.join(_BASE, "home")
for folder in (ROOT, HOME, os.path.join(_BASE, "tmp")):
os.makedirs(folder)
with open(os.path.join(HOME, ".gitconfig"), "w", encoding="utf-8") as f:
f.write("[user]\n\tname = Learner\n\temail = learner@example.com\n[init]\n\tdefaultBranch = main\n")
_MAC_GIT = "/Library/Developer/CommandLineTools/usr/bin"
_path = os.environ.get("PATH", "/usr/bin:/bin")
if os.path.exists(os.path.join(_MAC_GIT, "git")):
_path = _MAC_GIT + os.pathsep + _path
ENV = {
"PATH": _path,
"HOME": HOME,
"XDG_CONFIG_HOME": os.path.join(HOME, ".config"),
"TMPDIR": os.path.join(_BASE, "tmp"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"LANG": os.environ.get("LANG", "C.UTF-8"),
"LC_ALL": os.environ.get("LC_ALL", os.environ.get("LANG", "C.UTF-8")),
"TZ": "UTC",
}
# A fixed clock: the first command runs at this second (India Standard Time), each later one a minute after.
_state = {"clock": 1767240000, "cwd": ROOT}
def _tidy(line: str) -> str:
"""The line with the session's folders written the way the lesson shows them."""
for real, shown in ((ROOT, "~/demo"), (HOME, "~"), (_BASE, "…")):
line = line.replace(real, shown)
return line.rstrip()
def files(*names: str) -> None:
"""Copies files of the example's folder into ~/demo, the folder the session starts in."""
for name in names:
shutil.copy(os.path.join(HERE, name), os.path.join(ROOT, name))
def setup(script: str) -> None:
"""Runs the commands of `script` without showing them (the lesson shows them in the example's code); a command that
fails stops the example."""
run(script, show=False)
def run(script: str, mask: tuple = (), show: bool = True) -> None:
"""Types each line of `script` at the prompt and prints what it printed.
mask: (pattern, replacement) pairs applied to each line of output; a replacement of None leaves the line out.
"""
for command in script.strip("\n").splitlines():
command = command.strip()
if not command or command.startswith("#"):
if show:
print(command)
continue
if show:
print(f"$ {command}")
words = shlex.split(command)
if words[0] == "cd" and len(words) == 2:
where = HOME + words[1][1:] if words[1].startswith("~") else words[1]
target = os.path.normpath(os.path.join(_state["cwd"], where))
if os.path.isdir(target):
_state["cwd"] = target
elif show:
print(f"cd: no such file or directory: {words[1]}")
print("[exit status 1]")
else:
raise SystemExit(f"set-up command failed: {command}")
continue
_state["clock"] += 60
stamp = f"{_state['clock']} +0530"
env = {**ENV, "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
result = subprocess.run(command, shell=True, cwd=_state["cwd"], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
if not show:
if result.returncode != 0:
raise SystemExit(f"set-up command failed: {command}\n{_tidy(result.stdout)}")
continue
for line in result.stdout.splitlines():
line = _tidy(line)
for pattern, replacement in mask:
if replacement is not None:
line = re.sub(pattern, replacement, line)
elif re.search(pattern, line):
break
else:
print(line)
if result.returncode != 0:
print(f"[exit status {result.returncode}]") Finish with the self-check on fetching, pulling, publishing, refused pushes and leases: every answer comes with its reason, and the questions keep circling back to the idea you have got wrong most often.
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between git fetch and git pull?
git fetch downloads new commits from a remote and updates your remote-tracking branches, such as origin/main, and
nothing else: your local branches, your working tree and the staging area stay as they were. It is always safe, and
afterwards you can look at what arrived, for example with git log HEAD..@{upstream}. git pull runs that fetch and
then integrates the upstream of the current branch into it, by a fast-forward, a merge or a rebase, depending on
pull.ff, pull.rebase or the option you give. So pull can change your branch and your files, and when the histories
have diverged it can create a merge commit, rewrite your unpushed commits or stop with a conflict. Fetch first when you
want to see incoming work before it touches yours; pull when you already know what you will get.
Two harder questions start from the sessions of this lesson: a refused push with a colleague urging --force, and a
lease that a background fetch has renewed.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Your git push to main is rejected as non-fast-forward, and a colleague suggests git push --force. Explain what the rejection protects, why the suggestion is wrong, and the safe sequence of commands, including what you check before you push again.
-
Mid to senior
Explain what git push --force-with-lease protects against, describe a situation in which it still overwrites a colleague's commit, and say what --force-if-includes adds. Then give the commands you would use to update a rebased feature branch that a colleague also pushes to.
Key takeaways
git fetchmoves only remote-tracking branches such asorigin/main, so it is always safe, andgit log HEAD..@{upstream}then lists exactly what an integration would bring in.git pullis a fetch plus one integration step, and diverged branches need a policy: fast-forward only, rebase or merge, set once withpull.fforpull.rebase, or chosen for one pull with an option.- The first push of a branch sets its upstream with
git push -uorpush.autoSetupRemote;git push origin --deleteremoves a remote branch andgit fetch --pruneremoves the stale name. - A rejected push means the remote has commits you lack: fetch, integrate, test and push again, never with
--forceon a shared branch. - Rewrite only branches that are yours, and push them with
--force-with-lease --force-if-includes, so neither a stale view nor a background fetch can make you overwrite a commit you never saw.
Terms in this lesson
- --force-with-lease
- A git push option that forces the update only if the remote branch still points where your remote-tracking branch says it does, so it refuses to overwrite commits pushed since your last fetch.
- diverged branches
- Two branches that each have commits the other lacks since their last common commit. Neither can fast-forward to the other, so joining them takes a merge or a rebase.
References
- git-fetch documentation (Git project)
- git-pull documentation (Git project)
- git-push documentation (--force-with-lease, --force-if-includes, note about fast-forwards) (Git project)
- git-config documentation (pull.rebase, pull.ff, pull.autoStash, push.autoSetupRemote, fetch.prune) (Git project)
- gitrevisions documentation (@{upstream} and the two- and three-dot forms) (Git project)
- git-diff documentation (git diff A...B compares B with the merge base) (Git project)
- Git 2.30 release notes (git push --force-if-includes) (Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress