With authentication sorted out, the channel is open. Ana has uploaded task-manager to the team server — the exact mechanics of pushing are the subject of the next lesson — and for the first time in the course there are Git objects on a machine other than her own.
Now for the other direction of travel: receiving. And here we run into the most widespread confusion in the whole of Git, the one that causes the most alarm and fills the most forum threads: the difference between git fetch and git pull.
The confusion is understandable, because the names do not help: one word suggests going out to get something, the other suggests dragging it towards you, and in everyday speech they are near enough interchangeable. In Git they are nothing of the sort:
git fetchdownloads objects and updates your remote references. It does not touch a single file in your working tree. It is a completely harmless operation.git pullruns afetchand then immediately integrates what it downloaded into your current branch. It does modify your files and it can produce conflicts.
Understanding that difference changes your whole relationship with remotes: you move from "I run pull and see what happens" to "I look at what is there, I decide, and then I integrate". This lesson takes it apart, and Carla finally clones the project and catches up.
Contents
- The starting point: the project is already on the server
git fetch: what it actually does- Before and after a
fetch FETCH_HEAD: the reference almost nobody knows about- Inspecting what arrived before integrating it
- Integrating by hand: merging
origin/main git pull=fetch+ integration- The modes of
git pull - Cleaning up stale references:
--prune - Carla joins and catches up
- When histories diverge
- The starting point: the project is already on the server
Where the team stands right now:
flowchart TB
S["git.example.com<br/>task-manager.git (bare)<br/>main → c2a8f1e"]
A["Ana · Ubuntu<br/>main → c2a8f1e<br/>origin/main → c2a8f1e"]
B["Bruno · macOS<br/>main → c2a8f1e<br/>origin/main → c2a8f1e"]
C["Carla · Windows 11<br/>(nothing yet)"]
A --> S
S --> B
S -.->|"clone still to come"| C
Ana has published. Bruno has already updated his clone. Carla has nothing at all.
To reproduce this on your own machine, we build the whole scenario out of what the previous lessons taught:
mkdir -p /tmp/team && cd /tmp/team
# The server
git init --bare task-manager.git
# Ana's repository
git init -b main ana
cd ana
echo "<h1>Task manager</h1>" > index.html
git add . && git commit -m "Add initial task manager structure"
echo "body { font-family: sans-serif; }" > styles.css
git add . && git commit -m "Add base styles for the list"
echo "console.log('tasks');" > app.js
git add . && git commit -m "Add task deletion to the list"
echo "# Task manager" > README.md
git add . && git commit -m "Document installation in the README"
git remote add origin /tmp/team/task-manager.git
git push -u origin main
cd ..
# Bruno's clone
git clone /tmp/team/task-manager.git brunoFour commits on the server and two repositories in sync. That is our starting point.
git fetch: what it actually does
git fetch: what it actually doesBruno sits down to work in the morning. Ana has been adding things since yesterday and has pushed two new commits. Bruno has no idea: he remembers from lesson 04-01 that his origin/main is a photograph dated yesterday and that nobody is going to tell him otherwise.
Let us simulate Ana's work:
cd /tmp/team/ana
echo "console.log('counter');" >> app.js
git commit -am "Add the pending task counter"
echo "footer { color: gray; }" >> styles.css
git commit -am "Adjust the footer style"
git pushAnd now Bruno:
remote: Enumerating objects: 8, done. remote: Counting objects: 100% (8/8), done. remote: Compressing objects: 100% (4/4), done. remote: Total 6 (delta 2), reused 0 (delta 0), pack-reused 0 Unpacking objects: 100% (6/6), 612 bytes | 612.00 KiB/s, done. From /tmp/team/task-manager.git c2a8f1e..b4d7e93 main -> origin/main
The line that matters is the last one, and it repays a careful reading:
c2a8f1e..b4d7e93 main -> origin/main └───┬────────┘ └─┬─┘ └────┬────┘ from where to the branch the local where it has on the reference that moved server was updated
What has been updated is origin/main, not main. That arrow says so literally: the main branch on the server has been stored in your origin/main reference. It is the refspec from lesson 04-02 in action.
The three things a fetch does
- It connects to the remote and asks which references it has.
- It downloads the objects you are missing (commits, trees, blobs) and stores them in
.git/objects/. - It updates the remote references in
.git/refs/remotes/origin/according to the configured refspec.
The three things it does NOT do
- It does not touch your working tree. Not one file. Not one.
- It does not move any of your local branches. Your
mainstays exactly where it was. - It cannot produce conflicts. There is nothing to combine: objects are added and pointers move on references that are not yours.
The proof is immediate:
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
That closing nothing to commit, working tree clean is the point: the fetch has changed nothing on disk. The only thing that is new is that Git now knows two commits are waiting, because it can compare main against origin/main. Exactly where that message comes from and how it is worked out is the subject of lesson 04-06.
Two references, two different commits. This is the normal state of affairs after a fetch, and there is nothing odd about it.
The single most important practical consequence of this lesson:
git fetchis absolutely safe. It cannot break anything, it cannot lose work, it cannot produce a conflict and it cannot leave you halfway through anything. Run it whenever you like, even with uncommitted changes in your working tree. The only consequence is that you will be better informed.
Useful variants
# Fetch from every configured remote
git fetch --all
# Just one specific branch
git fetch origin main
# Bring the tags across too (lesson 05-05)
git fetch --tags
# See what would be fetched, without fetching anything
git fetch --dry-run
# Detailed output for every reference, not only the ones that change
git fetch --verboseAnd one that downloads nothing at all and is extremely handy for diagnosis:
b4d7e935f1c8a2e6d4b7f9a3c1e5b8d2f4a6c9e7 HEAD b4d7e935f1c8a2e6d4b7f9a3c1e5b8d2f4a6c9e7 refs/heads/main 7f3c9a28e4b1d6f9a2c5e8b3d7f1a4c6e9b2d5f8 refs/heads/docs/update-notes
It asks the server which references it has and which commits they point at, without downloading a single object. It is the cheapest way to check whether there is anything new or whether the remote is answering at all.
- Before and after a
fetch
fetchSeen on the graph, which is where it makes the most sense:
flowchart TB
subgraph BEFORE["BEFORE the fetch — Bruno's repository"]
direction LR
A1["1a4c8d6"] --> A2["8b6d3c2"] --> A3["4e7f2a9"] --> A4["c2a8f1e<br/><b>main</b><br/><b>origin/main</b>"]
end
subgraph AFTER["AFTER the fetch — Bruno's repository"]
direction LR
B1["1a4c8d6"] --> B2["8b6d3c2"] --> B3["4e7f2a9"] --> B4["c2a8f1e<br/><b>main</b>"] --> B5["9e2f4a7"] --> B6["b4d7e93<br/><b>origin/main</b>"]
end
BEFORE --> AFTER
Notice the two details that sum up the whole lesson:
- Two new commits have appeared (
9e2f4a7andb4d7e93) in Bruno's object database. They are there, complete, and readable offline. mainhas not moved. It is still onc2a8f1e, exactly where it was. Onlyorigin/mainhas advanced.
And because the objects are already on his disk, Bruno can examine them with everything he learned in module 2, with no network and without having integrated anything:
This is one of the great advantages of the distributed model: downloading and deciding are two separate acts. You can pull down the whole team's work on the train, on a patchy connection, and study it at your leisure afterwards.
FETCH_HEAD: the reference almost nobody knows about
FETCH_HEAD: the reference almost nobody knows aboutEvery git fetch writes a file at the root of .git/:
b4d7e935f1c8a2e6d4b7f9a3c1e5b8d2f4a6c9e7 branch 'main' of /tmp/team/task-manager.git 7f3c9a28e4b1d6f9a2c5e8b3d7f1a4c6e9b2d5f8 not-for-merge branch 'docs/update-notes' of /tmp/team/task-manager.git
FETCH_HEAD records what came down in the last download operation. It has three columns:
| Column | Contents |
|---|---|
| 1 | The hash of the downloaded commit |
| 2 | Empty if that branch is a candidate for integration; not-for-merge if not |
| 3 | Which branch and which URL it came from |
That not-for-merge is the key: it marks the branches that came across because of the refspec but that are not the one you would integrate. Only the tracking branch of your current branch is left unmarked.
You can use it like any other reference:
What it is good for in practice
First: it is the internal mechanism of git pull. When you run git pull, Git performs a fetch and then a git merge FETCH_HEAD. Knowing that strips the command of all its mystery: there is no magic, just two steps chained together.
Second: it lets you fetch from a URL without registering any remote. This comes up more often than you would think — for instance, when reviewing work from someone outside the team:
# Fetch a branch from a repository I want no permanent relationship with
git fetch https://git.example.com/carla/task-manager.git feature/prototype
# No remote reference has been created, but the commit is right here:
git log --oneline FETCH_HEAD -3
git switch -c prototype-review FETCH_HEADIt is ephemeral: every fetch overwrites FETCH_HEAD. If you are going to need that commit later on, create a branch or a named reference; otherwise garbage collection will eventually carry it off.
- Inspecting what arrived before integrating it
Here is the professional habit that separates the people who understand Git from the people who suffer it: between the fetch and the integration there is a gap, and that gap is for looking.
Bruno already has Ana's commits on his disk. Before mixing them into his own work, he studies them.
What am I missing?
Recall the semantics of the a..b range from module 2: "commits reachable from b but not from a". In other words: what the server has and I do not.
What do I have that they do not?
Empty: Bruno has committed nothing since his last synchronisation. If there were commits here and in the previous range, the histories would have diverged; that case is section 11.
Which files change, and by how much?
A quick look at the surface of the change. And if you want the detail:
diff --git a/app.js b/app.js
index 3f8a2c1..7d4e9b6 100644
--- a/app.js
+++ b/app.js
@@ -1 +1,2 @@
console.log('tasks');
+console.log('counter');The unified diff of lesson 02-05, with nothing different about it: origin/main is a reference like any other.
The whole graph
* b4d7e93 (origin/main) Adjust the footer style * 9e2f4a7 Add the pending task counter * c2a8f1e (HEAD -> main) Document installation in the README * 4e7f2a9 Add task deletion to the list * 8b6d3c2 Add base styles for the list * 1a4c8d6 Add initial task manager structure
The --all option includes every reference, remote ones included. It is the view to keep at hand the moment you start working with a remote.
Who did what?
The summary in a table
| Question | Command |
|---|---|
| What is new on the server? | git log --oneline main..origin/main |
| What have I got that is unpushed? | git log --oneline origin/main..main |
| How many commits on each side? | git rev-list --left-right --count main...origin/main |
| Which files change? | git diff --stat main origin/main |
| What exactly changes? | git diff main origin/main |
| What does the graph look like? | git log --oneline --graph --all |
| Who did it? | git log --format='%h %an: %s' main..origin/main |
This is the recommended flow: fetch → look → decide → integrate. It costs twenty seconds and saves you the bewilderment of a pull that has suddenly modified fifteen files you were not expecting.
- Integrating by hand: merging
origin/main
origin/mainBruno has seen what is there and he is happy with it. Now he integrates:
Updating c2a8f1e..b4d7e93 Fast-forward app.js | 1 + styles.css | 1 + 2 files changed, 2 insertions(+)
And this is exactly the git merge of module 3. Nothing new. Since Bruno had no commits of his own, main was simply behind without having diverged, and Git applied a fast-forward: moving the pointer forward. The same case we studied with local branches.
Now all three references agree: main, origin/main and the branch on the server.
This is the point at which it is worth pausing for a moment, because it delivers on the promise made at the end of module 3:
Integrating someone else's work is exactly the same
git mergeyou have used with your own branches. Fast-forward if you have not diverged, three-way merge if you have, with the possibility of conflicts you already know how to resolve. Remotes change none of that: all they change is where the commits come from.
git pull = fetch + integration
git pull = fetch + integrationAnd with that, git pull stops being mysterious:
git pullis a shortcut forgit fetchfollowed by an integration of the tracking branch into your current branch.
It really is that literal. These two blocks do the same thing:
flowchart TB
P["git pull"] --> F["1 · git fetch<br/>Downloads objects<br/>Updates origin/main"]
F --> M["2 · Integration<br/>merge (or rebase)<br/>of origin/main into main"]
M --> R["Working tree<br/><b>modified</b>"]
style F fill:#e8f4e8
style M fill:#f9e8e8
The two halves have opposite natures, which is why it pays to see them apart:
Step 1: fetch |
Step 2: integration | |
|---|---|---|
| Touches the network | Yes | No |
| Touches the working tree | No | Yes |
| Can produce conflicts | No | Yes |
| Easy to undo | Not needed | Yes (merge --abort, reset) |
| Safe without looking first | Yes | No |
When to use each
Use git fetch when:
- You want to know what is new without committing to anything.
- You have uncommitted changes and you want nothing to move.
- You are in the middle of something delicate.
- You want to review someone else's work before mixing it in.
- You are about to lose your connection and want everything downloaded.
Use git pull when:
- You are starting the day and simply want to catch up.
- You are sure you have not diverged.
- You are working on a branch nobody else touches.
An honest recommendation: if you are starting out, get into the habit of git fetch + look + integrate. It is one extra step and fifteen seconds, but it gives you the right mental model and avoids 90% of the frights. Once you understand perfectly well what is going to happen, git pull is a legitimate shortcut.
- The modes of
git pull
git pullThe fetch half of pull is always the same. What varies is how it integrates, and there are three possible behaviours. This is why, back in lesson 01-06, we configured:
--ff-only: only if it is a clean advance
It integrates only if the result can be reached with a fast-forward — that is, if your branch has no commits of its own. If you have diverged, it stops without doing anything:
This is not an error: it is the configuration working. Git is telling you "there is a decision to be made here and I am not going to make it for you". It is exactly what we wanted when we set pull.ff only in the initial configuration: never create merge commits by surprise.
It is the most conservative mode and the best one while you are learning.
--no-rebase: always merge
It integrates with git merge. If it can fast-forward, it does; if you have diverged, it creates a merge commit with two parents.
This was Git's historical default behaviour, and it is the reason so many repositories are littered with commits entitled Merge branch 'main' of https://…. Every one of them is somebody who ran git pull while diverged, without realising they were creating a commit.
That noise in the history is, precisely, one of the problems module 5 will tackle.
--rebase: reapply your commits on top
Instead of merging, it reapplies your local commits on top of what it fetched from the server, producing a linear history with no merge commits.
It is a powerful and very widely used technique, but it rewrites your local commits — it changes their hashes — and it comes with important rules about when you may and may not use it. It deserves a lesson of its own, and it has one: lesson 05-01: Rebase. Until then, just remember that the option exists and what it does in one sentence.
Side by side
| Mode | If you have not diverged | If you have diverged | History |
|---|---|---|---|
--ff-only |
Fast-forward | Stops | Linear |
--no-rebase (merge) |
Fast-forward | Merge commit | With forks |
--rebase |
Fast-forward | Reapplies your commits | Linear |
And the corresponding configuration, which you already know from lesson 01-06:
# What we have configured on this course
git config --global pull.ff only
# Alternatives (do not apply them now)
git config --global pull.rebase true # always rebase
git config --global pull.rebase false # always mergeYou can also fine-tune it per branch:
One detail: pull with uncommitted changes
If you have uncommitted modifications and the integration needs to touch those same files, Git refuses:
error: Your local changes to the following files would be overwritten by merge: app.js Please commit your changes or stash them before you merge. Aborting.
And rightly so: it is protecting work that exists only on your disk. The options are to commit, or to stash the changes temporarily with git stash (the subject of lesson 05-04).
Note that a git fetch in that same situation would have worked perfectly well, because it does not touch the working tree. One more reason to prefer it.
- Cleaning up stale references:
--prune
--pruneOver time a small but irritating problem appears. The team integrates the feature/task-counter branch and deletes it from the server. But in Bruno's repository, the reference origin/feature/task-counter stays there for ever.
Why? Because the default refspec only says what to fetch, not what to delete. A normal fetch never removes remote references.
origin/HEAD -> origin/main origin/main origin/feature/task-counter ← no longer exists on the server origin/feature/pending-filter ← no longer exists on the server origin/docs/update-notes
Two ghost references. And git remote show origin gives them away:
Remote branches:
main tracked
docs/update-notes tracked
feature/task-counter stale (use 'git remote prune' to remove)
feature/pending-filter stale (use 'git remote prune' to remove)That stale is exactly what we saw in lesson 04-02: "you have this reference, but it is no longer on the server."
The two ways to clean up
From /tmp/team/task-manager.git - [deleted] (none) -> origin/feature/task-counter - [deleted] (none) -> origin/feature/pending-filter
Pruning origin URL: /tmp/team/task-manager.git * [pruned] origin/feature/task-counter * [pruned] origin/feature/pending-filter
And to see what would be deleted without deleting anything:
Make it automatic
Since this has to be done all the time and has no downside whatsoever, the sensible thing is to configure it once and for all:
From now on, every fetch and pull of yours clears out the dead references. It is one of the settings a team repository with a high branch turnover appreciates most.
And if you also want to clear out tags deleted on the server:
This last one needs more care, because tags tend to be permanent and deleting one locally may not be what you want. Tags are the subject of lesson 05-05.
Very important: prune deletes remote references, never local branches or commits. If you have a local branch feature/task-counter, it is still there untouched after the prune. All that disappears is the note saying "the server had this branch".
- Carla joins and catches up
The moment we announced at the close of module 3 has arrived.
Carla, on her Windows 11 machine, with Git installed (lesson 01-02), configured (lesson 01-06) and her SSH key generated, loaded and uploaded (lesson 04-03), opens Git Bash and types the command Bruno ran in lesson 02-02:
Cloning into 'task-manager'... remote: Enumerating objects: 47, done. remote: Counting objects: 100% (47/47), done. remote: Compressing objects: 100% (28/28), done. remote: Total 47 (delta 15), reused 0 (delta 0), pack-reused 0 Receiving objects: 100% (47/47), 8.42 KiB | 8.42 MiB/s, done. Resolving deltas: 100% (15/15), done.
But this is not the same clone Bruno made. His was a newborn repository with two commits. This one has months of work inside it:
* b4d7e93 (HEAD -> main, origin/main, origin/HEAD) Adjust the footer style * 9e2f4a7 Add the pending task counter * c2a8f1e Merge the empty-list message |\ | * 6d3f8b2 Show a message when the list is empty * | 3b9e7d1 Add CSV export of the task list |/ * f7a3e92 Merge the pending task filter |\ | * b2e6d3f Restore field focus after adding a task * | 8d4e6b2 Merge the pending task counter |\ \ | |/ | * 9d1e4b7 Mark tasks as done on click * | c5d9b1e Document installation in the README |/
There is the entire story of module 3, with its three-way merges, its squash and its resolved conflict, downloaded in full onto Carla's laptop. She can consult any commit from any point in time with no connection at all because — as she knows from lesson 04-01 — her repository is complete and independent.
And everything lesson 02-02 said about what a clone leaves configured:
origin git@git.example.com:team/task-manager.git (fetch) origin git@git.example.com:team/task-manager.git (push)
Notice a detail that confuses a lot of people when they join a project: Carla has only one local branch (main), even though the server has several. The others exist in her repository as remote references, not as branches of her own. To work on one:
branch 'docs/update-notes' set up to track 'origin/docs/update-notes'. Switched to a new branch 'docs/update-notes'
Git saw that the local branch did not exist but origin/docs/update-notes did, worked out what she meant, and created the local branch with its tracking configured. That behaviour is called DWIM and it is one of the topics of lesson 04-06.
Her first working day
Carla is up and running. Her daily routine will look like this, and it is the routine of anyone working with a shared repository:
# 1. Start of the day: see what happened while I was asleep
git fetch --prune
git log --oneline main..origin/main# 3. Open a branch for her own work
git switch -c feature/date-order
# 4. Work, commit… and push (lesson 04-05)Three commands in the morning. That is the whole ritual.
- When histories diverge
One case is still missing, and it needs naming even though we will not develop it here.
Suppose Carla has committed two commits on main while Ana was pushing another three. The two histories have now diverged: each has commits the other does not. It is the scenario we studied in lesson 03-01 with local branches, except that now one of the two "branches" lives on another machine.
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)
With pull.ff only configured:
Once again: this is not a failure, it is the protection working. Git refuses to choose between merging and rewriting on your behalf, and it is right to: those decisions have different consequences for the team's history.
There are several ways out — merge explicitly, reapply your commits with rebase, or rethink the work — and each has its own implications and contraindications. All of that, with the criteria for choosing and the full procedures, is the subject of lesson 09-03: Resolving Divergence with the Remote.
Here it is enough that you recognise the message, that you understand why it appears (both sides have moved on separately from a common ancestor) and that you are clear that nothing has broken and nothing has been lost: there is simply a decision outstanding.
The same thing will happen in the opposite direction, when you try to push and the server rejects it. It is exactly the same situation seen from the other side, and we will meet it in the next lesson.
Common Mistakes and Tips
Mistake 1: believing git fetch did nothing because "the files look the same". It did precisely what it should: download objects and update origin/main. Check with git log --oneline main..origin/main and you will see everything it brought.
Mistake 2: running git pull blind and being surprised by the result. If you have diverged and your pull is in merge mode, you have just created a merge commit you may not have wanted. fetch + look + integrate costs fifteen seconds and removes the surprises.
Mistake 3: reading fatal: Not possible to fast-forward as a serious error. It is pull.ff only doing its job: there is divergence and Git will not decide for you. See 09-03.
Mistake 4: never using --prune. After a year you have forty origin/… references for branches deleted months ago, autocompletion is useless and git branch -r is unreadable. Set fetch.prune true and forget about it.
Mistake 5: thinking origin/main updates itself. It is a file on your disk. It changes only with fetch, pull, clone or a successful push. If a colleague tells you "it is already up there" and you cannot see it, your first command is git fetch.
Mistake 6: running pull with uncommitted changes and getting stuck on the error. The message is clear: commit or stash. And remember that a fetch in that same situation would have worked without trouble.
Mistake 7: confusing git remote prune with git prune. They are different commands: the first clears stale remote references; the second is a maintenance operation on the object database that removes unreachable objects. Do not mix them up.
Tip 1: make git fetch --prune the first command of your day. It is free, it is safe and it tells you where you stand. Then you decide what to do.
Tip 2: keep an alias for viewing the graph with the remote references.
With git gr you see at a glance where you are, where the server is and which branches sit in between. Aliases are covered in depth in lesson 06-04.
Tip 3: use git ls-remote to check the server without downloading anything. It is instantaneous and perfect for answering "is it up there yet?" or "is this remote responding?".
Tip 4: if you are about to travel or lose your connection, run git fetch --all --tags first. You take the whole team's work with you on disk and can review it, compare branches and read the history with no network.
Tip 5: git fetch is safe. Run it without fear. There is no situation in which a fetch spoils anything. Internalising that is what lets you work with remotes calmly.
Exercises
Exercise 1: prove that fetch touches nothing
Set up a bare server and two clones standing in for Ana and Carla. Then:
- Ana commits and pushes two commits.
- Carla, before doing anything, notes the hash of her
mainand of herorigin/main, and keeps a copy of the contents of a file. - Carla runs
git fetch. - Demonstrate with commands that: (a)
origin/mainhas changed, (b)mainhas not, (c) the file on disk is identical and (d) the new commits are already in her object database. - Now integrate and check what kind of integration Git performed, and why.
Exercise 2: the gap between fetch and integration
Starting from the previous exercise, and without integrating, answer each question with a single command:
- How many commits am I missing?
- Who wrote them and when?
- Which files do they modify, and by how many lines each?
- What exactly changes in
app.js? - Do I have any commit the server does not have?
- What does the graph look like with every reference?
Exercise 3: ghost references
- Create three branches on the server in addition to
main. - Clone and check that all three appear as remote references.
- Delete two of them on the server.
- Run a normal
git fetchand check that the ghost references are still there. - Explain why the
fetchdid not remove them. - Clean them up both possible ways and configure Git so it does not happen again.
Solutions
Solution 1:
mkdir -p /tmp/ex-fetch && cd /tmp/ex-fetch
git init --bare server.git
git clone server.git ana
cd ana
echo "<h1>Task manager</h1>" > index.html
echo "console.log('tasks');" > app.js
git add . && git commit -m "Add initial task manager structure"
git push -u origin main
cd ..
git clone server.git carla# 1. Ana works and pushes
cd /tmp/ex-fetch/ana
echo "console.log('counter');" >> app.js
git commit -am "Add the pending task counter"
echo "footer { color: gray; }" > styles.css
git add . && git commit -m "Adjust the footer style"
git push# 2. Carla records her state BEFORE
cd /tmp/ex-fetch/carla
git rev-parse main origin/main
md5sum app.js5a1d8f39c2e7b4a6f1d8c3e9b2a5f7d4c6e8b1a3 5a1d8f39c2e7b4a6f1d8c3e9b2a5f7d4c6e8b1a3 7c1e9a4b8d2f6c3a5e7b9d1f4a8c2e6b app.js
Exactly the same hash as before the fetch.
Same checksum, and git status --short with not a single line: the working tree is untouched.
# 4d. …but the new commits ARE already in her repository
git cat-file -t e4b7c92
git log --oneline main..origin/main
git show --stat e4b7c92 | head -8commit e4b7c923f8a1d6b4e2c7f9a3d5b8c1e6f4a2d7b9
Author: Ana Ferrer <ana.ferrer@example.com>
Date: Sat Aug 1 10:14:22 2026 +0200
Adjust the footer style
styles.css | 1 +This is the key demonstration: Carla can examine the full contents of commits she has not integrated, with no connection, because the objects are in her .git/objects/. What has not changed is her branch or her files.
Updating 5a1d8f3..e4b7c92 Fast-forward app.js | 1 + styles.css | 1 + 2 files changed, 2 insertions(+)
Fast-forward, and the reason is in the graph: Carla had committed nothing, so her main was a direct ancestor of origin/main. There was no divergence and no merge commit was needed: moving the pointer was enough. You can check it formally:
Solution 2:
Going back to the state just after the fetch and before the merge:
e4b7c92 · Ana Ferrer · 4 minutes ago · Adjust the footer style 9f2a5c8 · Ana Ferrer · 5 minutes ago · Add the pending task counter
diff --git a/app.js b/app.js
index 3f8a2c1..7d4e9b6 100644
--- a/app.js
+++ b/app.js
@@ -1 +1,2 @@
console.log('tasks');
+console.log('counter');The -- separates references from paths, so that Git need not wonder whether app.js is a file or a branch.
Nothing. I have not diverged, so the integration will be clean. The compact version of the same question:
Zero commits exclusively mine, two of theirs. (This syntax is explained in depth in lesson 04-06.)
* e4b7c92 (origin/main, origin/HEAD) Adjust the footer style * 9f2a5c8 Add the pending task counter * 5a1d8f3 (HEAD -> main) Add initial task manager structure
A single straight line with main behind: the visual image of "I can fast-forward".
Solution 3:
mkdir -p /tmp/ex-prune && cd /tmp/ex-prune
git init --bare server.git
git clone server.git work
cd work
echo "start" > f.txt
git add . && git commit -m "Add initial task manager structure"
git push -u origin main
# 1. Three more branches on the server
for r in feature/counter feature/filter docs/notes; do
git switch -c "$r" > /dev/null 2>&1
git commit --allow-empty -m "Work on $r" > /dev/null
git push -u origin "$r" > /dev/null 2>&1
done
git switch main
cd ..origin/HEAD -> origin/main origin/docs/notes origin/feature/counter origin/feature/filter origin/main
# 3. Delete two ON THE SERVER
cd /tmp/ex-prune/work
git push origin --delete feature/counter
git push origin --delete feature/filterorigin/HEAD -> origin/main origin/docs/notes origin/feature/counter origin/feature/filter origin/main
The two ghosts are still there. And git remote show confirms it:
Remote branches:
docs/notes tracked
main tracked
refs/remotes/origin/feature/counter stale (use 'git remote prune' to remove)
refs/remotes/origin/feature/filter stale (use 'git remote prune' to remove)5. Why the fetch did not remove them: because the default refspec, +refs/heads/*:refs/remotes/origin/*, only describes what to fetch. It is a copying instruction, not a synchronisation one: it says "whatever branches exist over there, store them here", and it says nothing about what to do with the ones that no longer exist. Deleting references is extra behaviour you have to ask for explicitly, and Git is conservative by default: it does not remove things unless you tell it to.
From /tmp/ex-prune/server - [deleted] (none) -> origin/feature/counter - [deleted] (none) -> origin/feature/filter
# 6c. Make sure it does not happen again
git config --global fetch.prune true
git config --global --get fetch.pruneFrom now on, every fetch and pull cleans up by itself. And one final reassuring check:
# Nor the objects: the commit of the deleted branch is still reachable by its hash
git cat-file -t 4c9e2a7 2>/dev/null || echo "(already collected)"prune deletes remote references, nothing more.
Conclusion
This lesson has undone the most widespread confusion in Git. The essentials:
git fetchdownloads objects and updates your remote references. It does not touch the working tree, it does not move your branches and it cannot produce conflicts. It is a completely safe operation you can run at any time, even with uncommitted changes.git pullisgit fetch+ an integration into your current branch. The first half is harmless; the second modifies your files and can produce conflicts. Seeing them separately is what takes the mystery out of the command.- The output
c2a8f1e..b4d7e93 main -> origin/mainmeans: the server'smainbranch has been stored in your localorigin/mainreference. Yourmainhas not moved. FETCH_HEADrecords what came down in the last fetch, with thenot-for-mergemark on the branches that are not candidates for integration. It is the internal mechanism ofpulland it lets you fetch from a URL without registering a remote.- Between the
fetchand the integration there is a gap, and it is for looking:git log main..origin/main,git diff --stat main origin/main,git log --oneline --graph --all. Twenty seconds that remove the surprises. - Integrating remote work is the same
git mergeof module 3: fast-forward if you have not diverged, three-way merge if you have. All remotes change is where the commits come from. - The modes of
pull:--ff-only(ours, thanks topull.ff only) stops at divergence instead of deciding for you;--no-rebasemerges and creates merge commits;--rebasereapplies your commits and is studied in lesson 05-01. git fetch --pruneandgit remote pruneremove the references of branches already deleted on the server, which a normalfetchnever clears. Setfetch.prune trueand forget about it. It deletes only remote references: never local branches or commits.- Carla is in: she has cloned a repository with the full history, she has
originconfigured, one local branch and remote references for the rest. - If the histories diverge,
pull --ff-onlystops. That is not a failure: it is a pending decision. The full treatment is in lesson 09-03.
What comes next
You now know how to receive. The other half is missing, and it is the one with consequences for the whole team: sending.
In lesson 04-05: Pushing Changes we will see what git push actually sends (objects and the update of a reference), what that -u in every tutorial does, how a push refspec is written and why it connects directly with what we studied in 04-02. And above all: why a push can be rejected, what non-fast-forward means exactly, and why --force-with-lease is almost always the right answer when --force can destroy a colleague's work without a trace.
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
