You pull the latest main and Git stops with CONFLICT (content): Merge conflict in checkout.js. You resolve it, and the log now shows a commit called Merge branch 'main' into feature/checkout sitting on top of the three commits you actually wrote. Four commits, one real change. A teammate reading that history next month can’t tell which is which.
git merge and git rebase both answer the same question: how do I bring my branch up to date with changes made elsewhere. They leave very different traces behind, and picking the wrong one on a shared branch causes real damage.
What merge and rebase do differently to your commit graph
Say main picked up commits D, E, F while you were working on feature, which has your own commits A, B, C branching off the old tip of main:
| |
git merge main, run from feature, creates one new commit with two parents: the tip of feature and the tip of main. Nothing about A, B, or C changes. Same hashes, same parents, same place in the graph:
| |
git rebase main, also run from feature, does the opposite. It leaves main untouched and replays A, B, and C one at a time on top of F, generating a new commit for each — A’, B’, C’ — with new hashes and new parents:
| |
The result looks exactly like you had branched off F in the first place and never diverged. That’s the entire appeal: a linear history, no merge commits in the log. The cost is that A, B, and C no longer exist as far as Git is concerned. A’, B’, and C’ replace them, and anyone who already pulled A, B, or C now holds commits that don’t match yours.
What a fast-forward merge is and when Git skips it
A merge needs a merge commit only when both branches have moved. If feature branched off F and main hasn’t moved since, merging feature into main has nothing to reconcile: Git slides the main pointer forward to C and stops. That’s a fast-forward, and it leaves the same flat history a rebase would:
| |
Git creates a merge commit only when both sides added commits the other doesn’t have. Some teams force one anyway, so every feature branch leaves a marker in main showing where it was integrated:
| |
How to clean up your commits with interactive rebase
Rebase also edits your own history, with no other branch involved. git rebase -i opens your last N commits in an editor, one line per commit:
| |
| |
Change the word at the start of a line and Git acts on it when the rebase runs:
| Command | Effect |
|---|---|
pick | Keep the commit as-is |
reword | Keep the commit, edit its message |
squash | Merge into the previous commit, combining both messages |
fixup | Merge into the previous commit, discarding this message |
drop | Remove the commit entirely |
edit | Pause here so you can amend the commit by hand |
Turn that list into squash e4f5a6b fix typo and fixup c7d8e9f WIP: still debugging and you end up with one commit, Add checkout validation, instead of three that only make sense in the order you wrote them. Do it before you open the pull request, not after someone has pulled your branch — squashing rewrites the hash of every commit from that point on, same as any other rebase.
If you fix up earlier commits as you go, git commit --fixup <hash> followed by git rebase -i --autosquash <base> moves each fixup next to its target and marks it for you, so you never reorder the todo list by hand.
How git pull --rebase keeps your branch flat
The same choice shows up every time you pull. A plain git pull, on a branch where you have local commits and the remote has moved on, does a fetch followed by a merge. That is how you get a Merge branch 'main' into feature/checkout commit for no reason other than bad timing. git pull --rebase fetches the same commits and replays your local ones on top of them:
| |
Uncommitted changes make the rebase refuse to start. Add --autostash and Git stashes them, rebases, then reapplies them:
| |
Set it once instead of typing the flag every time, per branch or globally:
| |
This is safe because it only ever rewrites commits that live on your local branch and nowhere else. The moment that stops being true, rebase stops being safe.
The golden rule: never rebase commits someone else has pulled
Rebase rewrites commit hashes. If you rebase a branch whose old commits someone else already has — they pulled your branch, or it’s main, or it’s anything with more than one contributor — their history and yours now disagree about what the branch is. Git has no way to know that your A and their A’ are the same logical change. It sees two unrelated commits, and the next merge between the two copies duplicates everything that diverged.
The rule that avoids this: rebase only branches nobody else has based work on. Your own feature branch, before you’ve given anyone a hash to build on, is fair game. A branch you’ve pushed and pointed teammates at, main, any long-lived shared branch: merge into those, never rebase them.
Rebasing a branch you’ve already pushed means force-pushing to make the remote accept the rewritten history:
| |
--force-with-lease refuses the push if the remote has commits you haven’t fetched yet — exactly the situation where someone built on the old history and a plain --force would silently discard their work. Never bare --force, and only on a branch you’re sure is yours alone.
How to recover from a bad rebase with git reflog
A rebase onto the wrong base, or an interactive rebase where you dropped the wrong commit, feels unrecoverable: the commits you had a minute ago don’t show up in git log anymore. They’re not gone. git reflog records every position HEAD has pointed to on your machine, rebases included:
| |
| |
HEAD@{3} is where your branch pointed right before the rebase started. Reset to it and you’re back to the exact state you had beforehand, dropped commits included:
| |
Reflog entries expire after 90 days by default, or 30 days for commits no other reference points to. It’s a safety net for the last bad hour, not storage. It’s also local only: it never gets pushed, and it can’t undo a rewrite that someone else already pulled.
The same instinct — get back to a known-good state after a bad change — applies outside Git history too. A Kubernetes Deployment keeps its own rollout history for the same reason, and kubectl rollout undo does for a bad deploy what git reset --hard against a reflog entry does for a bad rebase.
Git rebase vs merge: which to use when
| Situation | Use |
|---|---|
Bringing your local, unpushed branch up to date with main | rebase (or pull --rebase) |
| Cleaning up your own commits before opening a pull request | rebase -i |
Integrating a finished feature branch into main | merge (with or without --no-ff, per your team’s convention) |
The branch is shared, pushed, or is main itself | merge — never rebase it |
You need to know exactly when a feature landed in main | merge --no-ff — the merge commit marks it |
| You want a linear history with no merge commits in the log | rebase on your side, before you open the pull request |
The decision is really about who else has seen the commits. Only you? Rebase, and keep the history clean. At least one other person? Merge. A slightly messier log costs you nothing; a rewritten shared history costs everyone who pulled it an afternoon.
If your team has no convention yet, start here: pull.rebase = true locally, rebase -i to tidy your commits before you open the pull request, then a plain merge to land it in main. That gets you clean per-branch history without ever rewriting a commit someone else depends on, CI included. A workflow like Build and Push a Docker Image with GitHub Actions tags each build by commit SHA, and those SHAs need to still exist after the pipeline has run against them. Set git config --global pull.rebase true today; on its own it removes most of the accidental merge commits from your log.