Homeβ€Ί DevOpsβ€Ί Git Pull Explained: Fetch, Merge, and Avoid Team Chaos

Git Pull Explained: Fetch, Merge, and Avoid Team Chaos

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Git β†’ Topic 11 of 11
Git pull fetches and merges remote changes in one shot β€” here's exactly how it works, when it explodes, and how to use it safely in a real team.
πŸ§‘β€πŸ’» Beginner-friendly β€” no prior DevOps experience needed
In this tutorial, you'll learn:
  • git pull is always git fetch then git merge β€” understanding which half causes the risk (merge, not fetch) means you can safely inspect incoming changes before they touch your files.
  • Never run git pull with a dirty working tree. Stash first, pull, pop β€” those three commands in that order have saved me from losing in-progress work more times than I can count. It takes five seconds and costs nothing.
  • Reach for git pull --rebase when your local commits haven't been pushed yet and you want a clean linear history. Switch back to the default merge when you're integrating completed feature branches where the merge commit itself carries meaning.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Picture your team is editing a shared Google Doc, but everyone works offline on their own printed copy. Git pull is the moment you pick up the latest printout from the office printer and paste the new paragraphs into your own copy. If someone else changed the same sentence you changed, you've got a conflict to sort out before your copy makes sense. That's it β€” no magic, just syncing two versions of the same thing.

The most common way developers lose work on a team isn't a hard drive failure β€” it's a blind git pull on a branch with uncommitted changes, right before a demo, at 4:58pm on a Friday. I've watched it happen three times in my career and it never gets less painful.

When you're working solo, your local copy of the code is the truth. The moment a second person touches the same repo, your local copy starts drifting from reality. Every commit your teammate pushes is a change your machine doesn't know about yet. If you keep writing code on top of your stale copy, you're building on a foundation that no longer matches the shared codebase. Left unchecked, that drift turns into merge conflicts, overwritten work, and broken builds β€” the kind that wake people up at 3am.

By the end of this article you'll know exactly what git pull does under the hood, how to run it safely without nuking your local changes, what to do when it throws a merge conflict in your face, and the one flag that makes it dramatically safer to use on a shared branch. You'll be able to pull changes confidently, explain the difference between fetch and pull to a colleague, and recover cleanly when things go sideways.

What Git Pull Actually Does (It's Two Commands Wearing a Trenchcoat)

Before you can use git pull safely, you need to understand that it isn't one atomic operation β€” it's two separate commands stapled together. Every single time you run git pull, Git runs git fetch first, then git merge. That's the whole secret. Once you see it that way, its behaviour stops feeling mysterious.

git fetch goes to the remote server (usually called 'origin') and downloads any commits, branches, or tags that your local repo doesn't have yet. Critically, it does NOT touch your working files. It stashes the new data in a hidden reference called origin/main (or origin/whatever-your-branch-is) and leaves your actual code completely alone. You can run git fetch all day without risking a single line of your work.

git merge then takes that downloaded reference and integrates it into your current branch. This is the step that actually changes your files. If your work and your teammate's work touched different files, Git handles it automatically and you'll never even notice. If you both touched the same lines, Git stops and hands you a conflict to resolve manually. Understanding that fetch is always safe and merge is where the risk lives is the mental model that prevents 90% of beginner mistakes with this command.

understanding_git_pull.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334353637383940
# io.thecodeforge β€” DevOps tutorial

# ─────────────────────────────────────────────────────────────
# SCENARIO: You're on the 'main' branch of a team project.
# A colleague just pushed a bug fix to the remote repo.
# You need to get their changes before starting your next task.
# ─────────────────────────────────────────────────────────────

# Step 1: Check your current status before touching anything.
# Always do this. Know what state you're in before you pull.
git status
# Expected: 'nothing to commit, working tree clean'
# If you see modified files here β€” STOP. Stash or commit first.

# Step 2: See what branch you're on and where it sits vs. remote.
git log --oneline --decorate -5
# Shows the last 5 commits with branch pointers.
# Look for: HEAD -> main (your local) vs. origin/main (remote).
# If origin/main is ahead, you're behind β€” pull is needed.

# Step 3: Run fetch first to SEE what's coming, safely.
# This downloads remote changes but touches NOTHING in your working tree.
git fetch origin

# Step 4: Check what just arrived from the remote without merging yet.
# This shows commits on origin/main that aren't on your local main.
git log main..origin/main --oneline
# Output shows each incoming commit on its own line.
# If this is empty, you're already up to date.

# Step 5: Now merge the fetched changes into your local branch.
# This is the step that actually updates your files.
git merge origin/main

# ─── OR ────────────────────────────────────────────────────────
# Do both steps at once with git pull (only when working tree is clean).
git pull origin main
# Equivalent to: git fetch origin && git merge origin/main
# The 'origin' is the remote name. 'main' is the branch. Both are explicit here.
# Relying on defaults is fine locally but be explicit in scripts.
β–Ά Output
# After git fetch origin:
# From https://github.com/acme-corp/checkout-service
# a3f92c1..d7e841b main -> origin/main

# After git log main..origin/main --oneline:
# d7e841b Fix null pointer in PaymentProcessor when card token expires
# c2a190f Add retry logic to StripeClient on 429 response

# After git merge origin/main (or git pull origin main):
# Updating a3f92c1..d7e841b
# Fast-forward
# src/payments/PaymentProcessor.java | 12 ++++++------
# src/stripe/StripeClient.java | 8 ++++++++
# 2 files changed, 14 insertions(+), 6 deletions(-)

# Meaning: Git walked straight forward β€” no conflict, clean merge.
⚠️
Senior Shortcut: Fetch Before You CommitRun git fetch origin before you start writing new code in the morning. Then run git log HEAD..origin/main --oneline to see what your teammates shipped overnight. You'll catch collisions before you've written a single line β€” not after an hour of work. This takes four seconds and has saved me from ugly conflicts more times than I can count.

Running Git Pull Safely: The Three States Your Repo Can Be In

git pull behaves completely differently depending on the state of your working tree when you run it. There aren't two states β€” there are three, and each one needs a different response. Treating them all the same is exactly how people lose work.

State one: clean working tree. Nothing modified, nothing staged. This is the only state where git pull is safe to run without thinking. Git will fetch and merge without hesitation, and you'll get the remote changes cleanly.

State two: uncommitted changes that don't conflict with incoming changes. Git will actually let you pull here and it'll succeed β€” but this is a trap. You've now mixed your in-progress work with a merge commit in your history. It looks fine until you realise you can't bisect the history cleanly later, and your in-progress changes are now invisible in the merge commit's noise. Stash your work first, every single time.

State three: uncommitted changes that DO conflict with incoming changes. Git refuses to merge and throws 'error: Your local changes to the following files would be overwritten by merge'. This is actually the safe failure β€” Git is protecting you. Your fix is git stash, then git pull, then git stash pop, then resolve conflicts if any remain. Never force your way through this with git checkout -- file unless you genuinely want to throw that work away.

safe_pull_workflow.sh Β· BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
# io.thecodeforge β€” DevOps tutorial

# ─────────────────────────────────────────────────────────────
# SCENARIO: Mid-morning on the checkout-service team.
# You've been editing OrderValidator.java for 45 minutes.
# Your team lead just Slacked: "pushed the tax fix, pull when ready."
# Your working tree is NOT clean. Here's the safe workflow.
# ─────────────────────────────────────────────────────────────

# Step 1: Always check status first. Non-negotiable.
git status
# You see: 'modified: src/orders/OrderValidator.java'
# Do NOT pull yet.

# Step 2: Stash your in-progress changes.
# git stash is a stack β€” it saves your current diff and reverts the file.
# The message makes it identifiable when you have multiple stashes.
git stash push -m "WIP: adding discount threshold logic to OrderValidator"
# Output: Saved working directory and index state On main: WIP: adding...

# Step 3: Confirm working tree is now clean before pulling.
git status
# Output: 'nothing to commit, working tree clean' β€” safe to pull.

# Step 4: Pull the remote changes cleanly.
git pull origin main
# Output: Updating 9b1c233..e4f9021 β€” fast-forward or merge commit.

# Step 5: Re-apply your stashed work on top of the fresh code.
git stash pop
# Output: On branch main β€” your changes are restored on top of the new base.

# Step 6: Check if your work and the pulled changes conflict.
git status
# If OrderValidator.java shows 'both modified', you have a conflict to fix.
# If it shows 'modified' with no conflict markers β€” you're clean.

# ─────────────────────────────────────────────────────────────
# WHAT A CONFLICT LOOKS LIKE INSIDE OrderValidator.java:
# ─────────────────────────────────────────────────────────────
# <<<<<<< HEAD (your stashed changes after pop)
#   if (orderTotal > discountThreshold) {
# =======
#   if (orderTotal > 0 && taxRate != null) {  // team lead's tax fix
# >>>>>>> origin/main
#
# You must edit this file manually to combine both intentions,
# then run: git add src/orders/OrderValidator.java
# then run: git commit -m "Merge tax fix with discount threshold logic"
# ─────────────────────────────────────────────────────────────

# To see everything currently on the stash stack:
git stash list
# stash@{0}: On main: WIP: adding discount threshold logic to OrderValidator
β–Ά Output
# git stash push -m "WIP: adding discount threshold..."
# Saved working directory and index state On main: WIP: adding discount threshold logic to OrderValidator

# git pull origin main
# From https://github.com/acme-corp/checkout-service
# 9b1c233..e4f9021 main -> origin/main
# Updating 9b1c233..e4f9021
# Fast-forward
# src/orders/TaxCalculator.java | 6 +++---
# 1 file changed, 3 insertions(+), 3 deletions(-)

# git stash pop
# On branch main
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# modified: src/orders/OrderValidator.java
# Dropped refs/stash@{0}
⚠️
Production Trap: The Silent OverwriteIf you run git pull with staged but uncommitted changes and there's no conflict, Git merges silently and your staged diff gets folded into the merge commit. Your work isn't lost but it's buried β€” and if the CI pipeline runs immediately, it ships half-finished code. I watched this push an incomplete pricing rule to production on a Monday morning. The rule evaluated to zero for every order over Β£500. Always stash or commit before pulling, no exceptions.

git pull --rebase: The Flag That Keeps Your History Clean

Here's something the basic tutorials skip: the default merge strategy for git pull creates a merge commit every single time your history has diverged. On a busy team, this turns your git log into a spaghetti graph of endless 'Merge branch main into main' commits. After three months it's unreadable, bisecting is a nightmare, and code review is a chore.

The --rebase flag changes git pull's second step from a merge to a rebase. Instead of creating a new merge commit, Git replays your local commits on top of the freshly fetched remote commits. The result is a clean, linear history that reads like everyone worked in perfect sequence β€” even when they didn't. This is what the Git maintainers and most senior engineers actually use day-to-day.

The trade-off is worth knowing: rebasing rewrites your local commit SHAs. That's fine as long as you haven't pushed those commits anywhere. If you're rebasing commits that already exist on the remote β€” on a shared branch β€” stop. That causes the classic 'force push required' disaster where you rewrite history everyone else is building on. The rule is simple: rebase local-only commits freely, never rebase shared commits.

pull_rebase_vs_merge.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
# io.thecodeforge β€” DevOps tutorial

# ─────────────────────────────────────────────────────────────
# SCENARIO: You're working on the checkout-service feature branch.
# You've made 2 local commits (not pushed yet).
# The remote main has 3 new commits since you branched.
# Goal: get remote changes without polluting history with merge commits.
# ─────────────────────────────────────────────────────────────

# ── WITHOUT --rebase (default merge behaviour) ──────────────────
# Your history before pull:
# A - B - C (origin/main)
#          \
#           D - E (your local commits, not pushed yet)
#
# After: git pull origin main
# A - B - C --------- M (merge commit β€” clutters history)
#          \         /
#           D - E --/

# ── WITH --rebase ───────────────────────────────────────────────
# Your history before pull:
# A - B - C (origin/main)
#          \
#           D - E (your local commits)
#
# After: git pull --rebase origin main
# A - B - C - D' - E' (linear β€” D and E replayed on top of C)
# D' and E' are new SHAs but same changes β€” history reads cleanly.

# ─────────────────────────────────────────────────────────────
# Running it:
# ─────────────────────────────────────────────────────────────

# Check current state before pulling
git log --oneline -5
# Shows your 2 local commits on top

# Pull with rebase β€” safe because your commits are not yet on remote
git pull --rebase origin main

# If a rebase conflict occurs during the replay of your commits,
# Git pauses and shows you the conflict.
# Fix the file, stage it, then continue the rebase:
git add src/payments/CheckoutOrchestrator.java
git rebase --continue
# Do NOT run git commit here β€” rebase --continue does the commit for you.

# If the conflict is too messy and you want to abort and go back to where you started:
git rebase --abort
# This resets everything as if you never ran the rebase β€” clean escape hatch.

# ─────────────────────────────────────────────────────────────
# Make rebase the default for ALL future pulls (recommended):
# ─────────────────────────────────────────────────────────────
git config --global pull.rebase true
# Now every 'git pull' behaves like 'git pull --rebase' automatically.
# You can still override per-command with --no-rebase when you need a merge commit.
β–Ά Output
# git pull --rebase origin main (no conflict case)
# From https://github.com/acme-corp/checkout-service
# c3d2e1f..a7b9c04 main -> origin/main
# Successfully rebased and updated refs/heads/feature/discount-engine

# git log --oneline -5 (after rebase)
# 91fc3a2 (HEAD -> feature/discount-engine) Add percentage discount to CheckoutOrchestrator
# 55ab7e1 Add DiscountRule interface with matches() contract
# a7b9c04 (origin/main) Fix currency rounding in TaxCalculator for JPY
# e9d1b20 Remove deprecated CartService.applyPromo() method
# 3c2fa01 Add idempotency key to PaymentClient.charge()
# Notice: linear history β€” no merge commits.
πŸ”₯
Interview Gold: Why Rebase Rewrites SHAsEvery Git commit's SHA is a hash of its content PLUS its parent commit's SHA. When rebase replays your commit on a new parent, the parent SHA changes, so the child SHA changes too β€” even if your code change is byte-for-byte identical. This is why rebasing shared commits breaks teammates' branches: their local history still references the old SHAs that no longer exist on the remote. You can answer this question confidently now.

When Git Pull Goes Wrong: Reading the Error and Recovering Fast

Knowing the command is one thing. Knowing what to do when it breaks at the worst possible moment is what separates someone who can be trusted with production access from someone who can't. Here are the three failures you'll hit most often, with the exact recovery for each.

Merge conflicts aren't Git failures β€” they're Git doing its job. When two people edit the same lines, Git can't guess which version wins. It marks the conflict in the file and waits for you. The conflict markers are always the same: <<<<<<< HEAD is your version, >>>>>>> origin/branch is theirs, and ======= is the dividing line. Your job is to edit the file so it contains the correct final code, remove all the markers, stage the file, and complete the merge. Don't panic. It's just a text editing task.

The scarier failure is 'fatal: refusing to merge unrelated histories'. This happens when someone initialises a local repo and tries to pull from a remote that was created independently β€” common when a developer does git init locally, then the team lead creates the repo on GitHub and the developer tries to pull. Fix it with git pull origin main --allow-unrelated-histories, but understand what you're doing: you're telling Git to merge two completely separate histories. Review the result carefully before pushing.

conflict_recovery.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
# io.thecodeforge β€” DevOps tutorial

# ─────────────────────────────────────────────────────────────
# SCENARIO: You pulled on the inventory-service repo and hit a
# merge conflict in ProductRepository.java. Here's the recovery.
# ─────────────────────────────────────────────────────────────

# Step 1: After the failed pull, check what's in conflict.
git status
# Look for: 'both modified: src/inventory/ProductRepository.java'
# These are the files you must resolve before anything else works.

# Step 2: Open the conflicted file. You'll see this inside it:
# <<<<<<< HEAD
#   public List<Product> findByWarehouse(String warehouseId) {
#       return repository.findAll(warehouseId, StockFilter.ACTIVE);
# =======
#   public List<Product> findByWarehouse(String warehouseId, boolean includeReserved) {
#       return repository.findAll(warehouseId, includeReserved ? StockFilter.ALL : StockFilter.ACTIVE);
# >>>>>>> origin/main
#
# You need to decide: does the final version need both signatures?
# In this case: the remote version is a non-breaking extension β€” keep it.
# Edit the file to the correct final state, remove ALL conflict markers.

# Step 3: After manually editing, stage the resolved file.
git add src/inventory/ProductRepository.java

# Step 4: If there are more conflicted files, resolve and stage them too.
# Check: git status β€” repeat Step 2-3 for each 'both modified' file.

# Step 5: Complete the merge commit.
git commit
# Git opens your editor with a pre-filled merge commit message.
# Accept it or edit to describe what you resolved and why.
# Save and close β€” merge is complete.

# ─────────────────────────────────────────────────────────────
# ABORT OPTION: If you're overwhelmed and want to undo the whole pull:
# ─────────────────────────────────────────────────────────────
git merge --abort
# Resets your branch to exactly the state before you ran git pull.
# Safe to run any time DURING a conflicted merge (not after commit).

# ─────────────────────────────────────────────────────────────
# SCENARIO 2: 'fatal: refusing to merge unrelated histories'
# Happens when local repo and remote repo were created separately.
# ─────────────────────────────────────────────────────────────
git pull origin main --allow-unrelated-histories
# This is a one-time escape hatch. Do NOT add this flag routinely.
# After merging, inspect git log carefully β€” the combined history
# will look bizarre. That's expected. It's safe as long as the code works.
β–Ά Output
# git status (during conflicted merge)
# On branch main
# You have unmerged paths.
# (fix conflicts and run "git commit")
# (use "git merge --abort" to abort the merge)
#
# Unmerged paths:
# (use "git add <file>..." to mark resolution)
# both modified: src/inventory/ProductRepository.java

# After git add and git commit:
# [main 7a3c912] Merge branch 'main' of origin β€” resolved ProductRepository signature conflict

# git merge --abort (if you chose to bail out instead):
# (no output β€” silently resets to pre-merge state)
⚠️
Never Do This: git pull --forceThere is no --force flag on git pull, but developers sometimes reach for git fetch followed by git reset --hard origin/main to 'force pull'. This nukes every uncommitted change and every local commit that isn't on the remote. I've seen a junior dev on an e-commerce team wipe four hours of discount engine work this way because they panicked during a conflict. If you're stuck in a conflict, use git merge --abort β€” not a hard reset β€” unless you've explicitly committed or stashed everything you care about.
Aspectgit pull (merge)git pull --rebase
What it does under the hoodgit fetch + git mergegit fetch + git rebase
Creates merge commitsYes β€” one per diverged pullNo β€” replays your commits linearly
History shapeBranched graph (can become spaghetti)Linear, readable top-to-bottom
Safe on shared/pushed commitsYesNo β€” rewrites SHAs, breaks teammates
Conflict resolutionResolve once, then git commitResolve per-commit, then git rebase --continue
Abort mid-operationgit merge --abortgit rebase --abort
Best used whenMerging feature branches with contextSyncing local work-in-progress daily
Risk to uncommitted workLow if working tree is cleanLow if working tree is clean
Default behaviourYes (unless pull.rebase is configured)Opt-in or set pull.rebase true globally

🎯 Key Takeaways

  • git pull is always git fetch then git merge β€” understanding which half causes the risk (merge, not fetch) means you can safely inspect incoming changes before they touch your files.
  • Never run git pull with a dirty working tree. Stash first, pull, pop β€” those three commands in that order have saved me from losing in-progress work more times than I can count. It takes five seconds and costs nothing.
  • Reach for git pull --rebase when your local commits haven't been pushed yet and you want a clean linear history. Switch back to the default merge when you're integrating completed feature branches where the merge commit itself carries meaning.
  • The counterintuitive truth: a merge conflict isn't git pull failing β€” it's git pull succeeding at its most important job. It caught a collision and refused to guess which version wins. The real failure mode is a silent auto-merge that combines two contradictory changes without telling you.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Running git pull with modified files in the working tree β€” Error: 'error: Your local changes to the following files would be overwritten by merge: src/orders/OrderValidator.java. Please commit your changes or stash them before you merge.' β€” Fix: run git stash push -m 'descriptive message', then git pull, then git stash pop.
  • βœ•Mistake 2: Using git reset --hard origin/main to resolve a pull conflict instead of git merge --abort β€” Symptom: all local uncommitted changes and any unpushed commits silently disappear with no warning β€” Fix: never use reset --hard unless you've confirmed every change you care about is committed or stashed; use git merge --abort to safely back out of a conflicted pull.
  • βœ•Mistake 3: Running git pull --rebase on a branch whose commits are already pushed to the remote β€” Symptom: 'error: failed to push some refs β€” Updates were rejected because the tip of your current branch is behind its remote counterpart' followed by a confusing push rejection loop β€” Fix: only rebase commits that exist solely on your local machine; once a commit is pushed, merge instead of rebase.

Interview Questions on This Topic

  • Qgit pull is described as fetch plus merge β€” but what exactly does git fetch update, and what does it deliberately leave untouched? Why does that distinction matter in a CI/CD pipeline context?
  • QWhen would you configure pull.rebase true globally for an entire engineering team versus keeping the default merge strategy β€” and what's the specific workflow condition that makes rebase dangerous on a trunk-based development model?
  • QWhat happens if two developers both run git pull at the same moment, both have local commits, and both push immediately after β€” does the second push succeed, fail, or silently corrupt the shared branch? Walk through the exact sequence Git enforces.

Frequently Asked Questions

What does git pull actually do step by step?

git pull runs two commands in sequence: first git fetch, which downloads new commits from the remote into a hidden reference (like origin/main) without touching your files, then git merge, which integrates those downloaded commits into your current branch and updates your actual code. The fetch half is always safe. The merge half is where conflicts can surface if you and a teammate changed the same lines.

What's the difference between git pull and git fetch?

git fetch downloads remote changes but never modifies your working files β€” it's pure inspection. git pull does the same download and then immediately merges the changes into your branch. Use git fetch when you want to see what's coming before committing to the merge; use git pull when your working tree is clean and you're ready to integrate the changes immediately.

How do I pull changes without overwriting my local work?

Run git stash push -m 'your description' before pulling. This saves your in-progress changes to a temporary stack and reverts your files to a clean state. Then run git pull, then git stash pop to restore your work on top of the newly pulled code. If the stash pop surfaces a conflict, resolve it the same way you'd resolve any merge conflict β€” edit the file, remove conflict markers, git add, git commit.

In a high-velocity team doing 50+ commits a day to main, should we use git pull or git pull --rebase β€” and what breaks if we get it wrong?

Use git pull --rebase for daily sync of unpushed local work β€” it keeps the main branch history linear and bisectable, which matters enormously when you're hunting a regression across 50 commits. The failure mode to avoid: if a developer rebases commits that are already on the remote (pushed earlier that session), their local and remote histories diverge, the push gets rejected, and the temptation to force-push follows β€” which rewrites shared history and breaks every teammate's branch simultaneously. Set pull.rebase true globally but enforce a rule: never rebase after pushing. Some teams enforce this with a pre-push hook that counts ahead commits and rejects a rebase if the branch is already tracking a remote.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousGit Clone: Clone a Repository Step by Step
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged