> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The only correct way to git pull and merge

> A strongly opinionated guide to git pull and merge strategies that will make your commit history actually useful. Learn when to rebase, when to merge, and why the defaults are lying to you.


export const PostMeta = ({data = {}}) => {
  const {author, date, image} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const linkedin = entry.linkedin || null;
    const url = github ? `https://github.com/${github}` : linkedin || null;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (!image && authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} target="_blank" rel="noopener noreferrer" className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
      {image && <img src={image} alt="" className="post-meta-image" aria-hidden="true" />}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-02-03T10:27:00.000Z", image: "/images/posts/insights/the-only-correct-way-to-git-pull-and-merge/the-only-correct-way-to-git-pull-and-merge.webp" }} />

If your git history is full of merge commits that don't seem to mean anything, you're in good company. Git's default behavior is actively working against you. The good news? A couple of configuration changes will fix it.

## The problem with `git pull`

Here's what happens when you run `git pull` with the default settings. You're working on a branch with your teammates. You've got a few commits. Everything's fine. Then you think, "I should grab the latest changes." Reasonable! So you pull.

And git creates a merge commit.

You do this three times during the day because you want to stay up to date. Now you have three merge commits that say absolutely nothing except "I, a developer, updated my local copy of the repository." Thanks for that vital historical information, git.

```text theme={null}
Your branch with default git pull:

main:
A───B───C───D───E───F
     \   \   \       \
      \   \   ├───M2──├───M3
       \   \  │       │
        \  ├──M1      │
         \ │          │
          yours──────yours'

M1 = "Merge branch 'main' into feature" (you pulled at 10am)
M2 = "Merge branch 'main' into feature" (you pulled at 2pm)
M3 = "Merge branch 'main' into feature" (you pulled at 5pm)
```

These commits don't tell you anything about how the code evolved. They don't show meaningful changes. They're noise. Your git history now reads like a diary of your sync habits rather than a story of your codebase.

## What GitLab figured out

Here's the thing: GitLab doesn't even offer you the option to create a merge commit when updating your branch. When your merge request is behind the target branch, you get a "Rebase" button. That's it. No merge commit option.

<img src="https://mintcdn.com/upsun-c9761871/eXV27YLeu5lESGxN/images/posts/insights/the-only-correct-way-to-git-pull-and-merge/gitlab-rebase-button.webp?fit=max&auto=format&n=eXV27YLeu5lESGxN&q=85&s=2c96da34a6d43665d758ba01deed997f" alt="GitLab's Rebase button - no merge commit option in sight" width="1047" height="271" data-path="images/posts/insights/the-only-correct-way-to-git-pull-and-merge/gitlab-rebase-button.webp" />

GitLab understood that there's no value in a merge commit that only says "I got the latest code." The history should show what actually happened to the code, not your personal workflow habits.

```text theme={null}
Your branch with rebase (what you want):

Before rebase:
main:    A───B───C───D───E───F
              \
               X───Y  (your commits)

After git pull --rebase:
main:    A───B───C───D───E───F
                              \
                               X'──Y'  (your commits, rebased)
```

No merge commits. Your commits sit on top of the latest main. The history tells a clean story.

## When merge commits actually matter

Merge commits aren't evil. They're valuable when you're merging a branch into main. That's the moment when you want to show: "All of these commits were developed together, and they're landing in main as a unit."

The merge commit groups related work. When you're debugging six months from now and you find a problematic line, you can trace it back to its merge commit and see the full context. That refactoring commit? It was part of a larger feature. That feature? Here's every commit that went into it, all grouped together.

```text theme={null}
A meaningful merge commit:

main:    A───B───C───────────────M───
                  \             /
feature:           D───E───F───G
                   │           │
                   └───────────┘
                   "Add user authentication"

M = "Merge 'Add user authentication' into main"

Looking at M, you can see: commits D, E, F, G all belong together.
They implemented one feature. That's useful context.
```

This is useful information. "I pulled at 2:47 PM" is not.

## The fast-forward trap

When you merge without the `--no-ff` flag, git will fast-forward if it can. Your branch commits get added directly on top of main, and it looks like they were developed there all along.

This erases context. You lose the information about which commits belonged together. The history looks linear, but it's a lie. Those commits were developed on a branch, tested together, and merged as a unit. That information matters when you're trying to understand why code exists.

```text theme={null}
Fast-forward merge (git merge feature):

Before:
main:    A───B───C
                  \
feature:           D───E───F

After:
main:    A───B───C───D───E───F
                     │       │
                     └───────┘
                     Where did these come from?
                     Were they developed together?
                     Who knows! The context is gone.
```

```text theme={null}
No-fast-forward merge (git merge --no-ff feature):

Before:
main:    A───B───C
                  \
feature:           D───E───F

After:
main:    A───B───C───────────M
                  \         /
                   D───E───F

M = merge commit that preserves the branch structure.
You can always see: D, E, F were developed together.
```

Use `--no-ff` when merging to main. Always.

## The squash merge problem

Squash merging takes all the commits from your feature branch and combines them into a single commit on main. It sounds tidy. It's actually throwing away information.

```text theme={null}
Squash merge (git merge --squash feature):

Before:
main:    A───B───C
                  \
feature:           D───E───F───G
                   │   │   │   │
                   │   │   │   └── "Fix edge case in auth"
                   │   │   └────── "Add login form"
                   │   └────────── "Set up auth service"
                   └────────────── "Add user model"

After:
main:    A───B───C───S

S = "Add user authentication"

Where did commits D, E, F, G go? Gone.
Why was there an edge case fix? Who knows.
How did this feature evolve? Mystery.
```

When you squash, you lose the story of how the feature was built. That refactoring commit that seemed unrelated? It was preparing for the next step. That bug fix at the end? It reveals an edge case you might hit again. The individual commits show the developer's reasoning, the problems they encountered, the decisions they made.

Six months from now, when you're debugging and you find that the auth service is doing something unexpected, you won't be able to trace back through the thought process. You'll just see one big commit that says "Add user authentication" and wonder why things are the way they are.

## The configuration you actually want

Here's how to fix your git defaults:

```bash theme={null}
git config --global pull.rebase true
```

Now `git pull` rebases by default instead of creating pointless merge commits. Your history stays clean. Your teammates will thank you. Or at least they won't curse your name when reviewing the commit log.

For merges, get in the habit:

```bash theme={null}
git merge --no-ff feature-branch
```

Or configure your merge strategy in your repository settings if your git hosting provider supports it.

## The summary

1. **Configure git to rebase on pull**: `git config --global pull.rebase true`
2. **Use `--no-ff` when merging branches**: Preserve the context of grouped work
3. **Avoid squash merges**: Keep the development history intact

## A note on opinions

This is an opinionated piece, and reasonable people disagree on these things. Some teams love squash merges because they keep the main branch looking clean. Some developers prefer merge commits on pull because they mark a clear point in time. There are arguments for all of these approaches, and your team might have good reasons for doing things differently.

What matters most is that your team agrees on a workflow and sticks to it consistently. A consistent approach beats the "right" approach applied inconsistently.

That said, if you're starting fresh or reconsidering your workflow, the approach outlined here optimizes for one thing: making your git history useful when you need to understand why code exists. Your future self, debugging at 11 PM, will appreciate a history that tells a story rather than a logbook of sync operations or a series of opaque squashed commits.
