Rebase vs Merge

When you need to bring changes from one branch into another, Git gives you two different tools for the job: git merge and git rebase. Both end up combining the same code changes, but they produce very different commit histories. merge preserves exactly what happened, including a record of when branches diverged and came back together, while rebase rewrites history into a straight, linear story as if the work had happened in a different order than it really did. Picking the right one — and knowing when rebasing is outright dangerous — is one of the most important judgment calls in day-to-day Git use.

Overview / How Rebase and Merge Work

To understand the difference, it helps to remember what a branch actually is. A commit object stores a snapshot of your project (via a tree of blobs), a pointer to its parent commit(s), and metadata like author and message; its SHA-1 (or SHA-256 on newer repos) hash is computed from all of that, including the parent pointer. A branch like main or feature/login-page is nothing more than a small text file containing a commit hash — a movable label that Git advances automatically each time you commit while that branch is checked out. HEAD normally points at the current branch, which in turn points at a commit.

git merge takes the tip of another branch and integrates it into your current branch. If your branch hasn’t moved since it diverged, Git can do a fast-forward merge: it just slides the branch pointer forward to the other branch’s tip, since your history already contains all of its commits — no new commit is created. If both branches have new work, Git performs a three-way merge using the common ancestor (the merge base) plus the two tips, and creates a new merge commit with two parents. Nothing about the existing commits changes; their hashes stay identical, and the history honestly shows that two lines of development happened in parallel and were joined.

git rebase takes a different approach: it moves your branch to start from a new base commit and replays your commits, one at a time, on top of it. Each replayed commit is a brand-new commit object — same snapshot content (usually), but a different parent, which means a different SHA. The original commits aren’t touched (they still exist in Git’s object database until garbage collected, and you can find them via git reflog), but your branch pointer now references the new commits, and the old ones are orphaned. The result is a straight line of history with no merge commits, as if you had branched off from the latest code and written your commits from there. During a rebase, Git temporarily checks out commits directly by hash — this is a detached HEAD state — which is why an interrupted rebase can leave you looking at a HEAD that isn’t attached to any branch until you finish or abort.

The golden rule of rebasing

Because rebase creates new commits with new hashes, it rewrites history. Never rebase a branch that other people have already pulled or built work on top of. If you rebase main (or any shared branch) after teammates have based their own branches on the old commits, their history and yours will diverge — Git will see the old and new commits as unrelated, and everyone downstream will hit painful, confusing conflicts. Rebase freely on your own private feature branches before you share them; once a branch is pushed and others may be using it, prefer merge (or coordinate very carefully).

Syntax

git merge <branch>
git merge --no-ff <branch>
git merge --abort

git rebase <branch>
git rebase -i <branch>
git rebase --continue
git rebase --abort
git rebase --skip
Command / flag What it does
git merge <branch> Integrates <branch> into the current branch; fast-forwards if possible, otherwise creates a merge commit.
--no-ff Forces a merge commit even when a fast-forward is possible, keeping a visible record that a branch existed.
--abort (merge) Cancels a merge that has conflicts and restores the pre-merge state.
git rebase <branch> Replays the current branch’s commits on top of <branch>‘s tip.
-i, --interactive Opens an editable list of commits so you can reorder, squash, reword, or drop them while rebasing.
--continue Resumes a rebase after you’ve resolved a conflict and staged the fix.
--abort (rebase) Cancels the rebase entirely and returns the branch to its original state.
--skip Skips the commit currently causing a conflict and continues with the rest.

Examples

Example 1: Merging two branches

git switch -c feature/login-page
echo "console.log('login form');" > login.js
git add login.js
git commit -m "feat: add login form skeleton"

git switch main
echo "console.log('navbar');" > navbar.js
git add navbar.js
git commit -m "feat: add navbar component"

git switch feature/login-page
git merge main

Output:

Merge made by the 'ort' strategy.
 navbar.js | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 navbar.js

Both branches had new commits since they diverged, so Git could not fast-forward. It created a new merge commit on feature/login-page with two parents — the tip of feature/login-page and the tip of main — bringing in navbar.js while keeping every original commit, on both branches, unchanged.

Example 2: The same situation with rebase

git switch feature/login-page
git rebase main

Output:

Successfully rebased and updated refs/heads/feature/login-page.

Instead of creating a merge commit, Git found the common ancestor of feature/login-page and main, temporarily set feature/login-page aside, and re-applied its one commit (feat: add login form skeleton) on top of main‘s tip. The result is a straight line: navbar commit, then login-form commit, with no merge commit anywhere. The login-form commit now has a new SHA, because its parent changed.

Example 3: Rebasing a feature branch before opening a pull request

A common real-world workflow is to rebase your feature branch onto the latest main right before opening (or updating) a pull request, so the PR shows a clean, linear diff. Since you’ve already pushed this branch earlier, you must force-push afterward — but with --force-with-lease, not bare --force, so the push fails safely if someone else has added commits to the remote branch that you haven’t seen yet.

git switch feature/login-page
git fetch origin
git rebase origin/main

Output (a conflict occurs):

Auto-merging login.js
CONFLICT (content): Merge conflict in login.js
error: could not apply 5a3f21c... feat: add login form skeleton
Resolve all conflicts manually, mark them as resolved with
"git add/rm <pathspec>", then run "git rebase --continue".

You open login.js, remove the conflict markers, keep the correct content, then continue:

git add login.js
git rebase --continue
git push --force-with-lease origin feature/login-page

Output:

Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Writing objects: 100% (4/4), 512 bytes | 512.00 KiB/s, done.
Total 4 (delta 2), reused 0 (delta 0)
 + 5a3f21c...9c1e4ab feature/login-page -> feature/login-page (forced update)

The (forced update) line confirms the remote branch’s history was rewritten to match your rebased local branch. Because you used --force-with-lease, this would have failed loudly instead of silently overwriting anyone else’s commits if the remote had moved since your last fetch.

How It Works Step by Step

Merge (three-way):

  • Git finds the merge base — the most recent commit both branches share.
  • It performs a three-way comparison between the merge base, your branch’s tip, and the other branch’s tip.
  • Non-conflicting changes from both sides are combined into a new tree; conflicting changes are marked for manual resolution.
  • A new commit is created with two parents, referencing that combined tree.
  • Your current branch pointer moves forward to this new merge commit. No existing commit is altered.

Rebase:

  • Git finds the merge base of your branch and the target branch.
  • It makes a list of every commit on your branch since that merge base.
  • It checks out the target branch’s tip directly (a detached HEAD).
  • It replays each of your commits, one by one, as if you had run git cherry-pick on each — applying its diff and creating a brand-new commit object with the current tip as its parent.
  • If a replayed commit’s changes conflict with the new base, the rebase pauses so you can fix the file, git add it, and run git rebase --continue.
  • Once every commit has replayed, Git moves your branch’s pointer to the last new commit. The old commits become unreferenced (recoverable briefly via git reflog until garbage collection).

Common Mistakes

Mistake 1: Rebasing a shared branch that others depend on.

git switch main
git rebase feature/login-page
git push --force origin main

This rewrites every commit on main and force-pushes over the shared history. Anyone who already pulled the old main now has a branch that has diverged from the remote — their next pull will show duplicated, conflicting commits. Never rebase main or any branch teammates build on; merge into it instead, or rebase only your own not-yet-shared feature branches.

Mistake 2: Force-pushing with bare --force instead of --force-with-lease.

git push --force origin feature/login-page

Bare --force overwrites whatever is on the remote unconditionally, even if a teammate pushed a commit to that same branch five minutes ago — their work is silently discarded. git push --force-with-lease origin feature/login-page checks that the remote branch is still at the commit you last saw before overwriting it, and refuses if it isn’t, giving you a chance to fetch and investigate first.

Mistake 3: Panicking mid-rebase and leaving conflict markers in a commit.

<<<<<<< HEAD
const greeting = "Hello";
=======
const greeting = "Hi there";
>>>>>>> 5a3f21c (feat: add login form skeleton)

If you run git add and git rebase --continue without actually removing the <<<<<<</=======/>>>>>>> markers, they get committed as literal code — often breaking the build. Always open the flagged file, resolve to the intended final content, remove every marker, and consider running your test suite before continuing.

Best Practices

  • Rebase local, unpushed, or solely-yours branches; merge (or coordinate a force-push) once a branch is shared.
  • Prefer git push --force-with-lease over bare --force whenever you must force-push after a rebase.
  • Use git rebase -i to clean up messy work-in-progress commits (squash, reword) before opening a pull request, for a readable history.
  • Use merge --no-ff on long-lived integration branches when you want a permanent record that a feature branch existed, even if it could fast-forward.
  • Run git fetch before rebasing onto a remote-tracking branch (origin/main) so you’re replaying onto the latest code, not a stale local copy.
  • If a rebase goes wrong, git rebase --abort is always safe before you’ve finished — it restores your branch exactly as it was.
  • Write commit messages using a consistent style (e.g. Conventional Commits: feat:, fix:, chore:) so a rebased, linear history stays easy to scan.

Practice Exercises

  • Create a repo, branch off feature/search-bar, and make two commits on it while also making one commit directly on main. Merge main into your feature branch and inspect the result with git log --graph --oneline --all. Then undo it, and instead rebase the feature branch onto main; compare the two resulting histories.
  • Simulate a conflicting rebase: edit the same line of the same file differently on two branches, then rebase one onto the other. Resolve the conflict, run git rebase --continue, and confirm the file has no leftover conflict markers.
  • Push a feature branch to a remote, rebase it locally onto an updated origin/main, then push with git push --force-with-lease. Explain, in your own words, what would have happened differently if you’d used bare --force and a teammate had pushed a commit to that branch in the meantime.

Summary

  • git merge combines branches by creating a new commit with two parents (or fast-forwarding); it never rewrites existing commits.
  • git rebase replays your commits onto a new base, creating new commit objects with new hashes; it rewrites your branch’s history.
  • A branch is just a movable pointer to a commit; rebase moves that pointer to a new chain of commits, while merge moves it to one new merge commit.
  • Never rebase a branch that others have already pulled or built work on — that’s the golden rule of rebasing.
  • After rebasing a previously-pushed branch, force-push with --force-with-lease, never bare --force.
  • Use git rebase --abort or git merge --abort to bail out cleanly if a conflict resolution goes wrong.