The Golden Rule of Rebasing

Rebasing lets you rewrite a branch’s history: replaying its commits onto a new starting point so the result looks like you built your work on top of the latest code from the start. It’s one of the most useful commands in Git for keeping history clean — and one of the most dangerous, because rewriting history means the old commits are replaced by new ones with different identities. This lesson explains the single rule that keeps rebase safe to use: never rebase commits that other people already have a copy of.

Overview: What Rebase Actually Rewrites

To understand why the golden rule exists, you need to remember what a commit is. Every commit object in Git stores a pointer to a tree (a snapshot of the project’s files, itself made of blobs and sub-trees), a pointer to its parent commit (or commits, for a merge), an author, a message, and a timestamp. Git hashes all of that data with SHA-1 to produce the commit’s ID. A branch like main or feature/login-page is nothing more than a small file holding one of these commit IDs — a movable pointer. HEAD normally points at the current branch, which in turn points at a commit.

git merge combines two histories by creating a brand-new commit with two parents, leaving every existing commit untouched. git rebase works completely differently: it finds the commits that exist on your branch but not on the branch you’re rebasing onto, and replays each one, in order, as a new commit on top of the new base.

Here is the part that matters most: a commit’s SHA-1 is computed from its content and its parent’s SHA-1. When rebase gives a commit a new parent, the hash changes, even if the file changes inside it are byte-for-byte identical to before. Rebase does not edit or move your existing commits — it creates entirely new commit objects and then moves the branch pointer to the last new one. The original commits become unreachable from the branch (though they linger in the repository, recoverable through git reflog, until Git eventually garbage-collects them).

This is exactly why rebase is dangerous on shared history. If you’ve already pushed commits and a teammate has fetched or pulled them, they now have a copy of the old commit objects. If you rebase and force-push, your branch now points at a set of new commit objects with different hashes but similar-looking content. Git has no way of knowing “these are the same change, just relocated” — as far as it’s concerned, your teammate’s copy and your new copy are two unrelated sets of commits that happen to make similar changes. That’s the golden rule: never rebase a branch, or any commits, that someone else has already pulled or might have built work on top of. If it hasn’t left your machine, or only exists on a private branch nobody else uses, rebase freely. If it has been shared, don’t rewrite it — integrate with git merge instead, or coordinate explicitly with your team first.

Syntax

The basic form replays the commits unique to your current branch onto another commit (usually a branch tip):

git rebase "<upstream>"
git rebase -i "<upstream>"
git rebase --onto "<newbase>" "<upstream>" "<branch>"
git rebase --continue
git rebase --skip
git rebase --abort
Form / flag What it does
git rebase <upstream> Replays the commits on the current branch that aren’t on <upstream>, on top of <upstream>‘s tip.
-i, --interactive Opens an editable list (the “todo list”) of the commits about to be replayed, letting you reorder, reword, squash, or drop them.
--onto <newbase> <upstream> <branch> Replays only the commits between <upstream> and <branch> onto <newbase> — useful for moving a range of commits, not just “everything since we diverged.”
--continue Resumes a rebase after you’ve resolved a conflict and staged the fix.
--skip Skips the commit currently being replayed entirely (its change is dropped).
--abort Cancels the rebase in progress and puts the branch back exactly where it was before you started.
--autosquash Automatically reorders fixup!/squash! commits next to the commit they target during an interactive rebase.

Examples

Example 1: Rebasing a private feature branch onto updated main

This is the safe, everyday case: a feature branch that only you have been working on, nobody else has pulled it, and you want to bring in the latest main before opening a pull request.

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

Output:

First, rewinding head to replay your work on top of it...
Applying: feat: add login form validation
Applying: feat: wire up login API call
Successfully rebased and updated refs/heads/feature/login-page.

Git found the common ancestor of feature/login-page and origin/main, temporarily set the branch tip to origin/main, and re-applied your two commits as brand-new commits on top. Your branch now looks as if you’d started it from today’s main instead of last week’s.

Example 2: Cleaning up local commits with interactive rebase before opening a PR

You made a few messy “fix typo” commits while working. Since none of this has been pushed yet, it’s completely safe to tidy it up.

git switch feature/login-page
git rebase -i HEAD~3

Git opens your editor with a todo list like this:

pick 7a3f9c1 feat: add login form
pick 2b8e4d0 fix: typo in label
pick 9f1a2c3 fix: another typo

You change the last two lines from pick to squash (or s) and save:

pick 7a3f9c1 feat: add login form
squash 2b8e4d0 fix: typo in label
squash 9f1a2c3 fix: another typo

Git then prompts you to write a combined commit message, and finishes with:

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

The three original commits are gone from the branch, replaced by one clean commit — feat: add login form — that a reviewer can actually read.

Example 3: Resolving a conflict during a rebase

Rebasing doesn’t always apply cleanly. Here feature/checkout-flow and main both changed the same line of src/cart.js.

git switch feature/checkout-flow
git rebase main

Output:

Auto-merging src/cart.js
CONFLICT (content): Merge conflict in src/cart.js
error: could not apply 4e1a2f3... feat: apply discount before tax
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm ", then run "git rebase --continue".

You open src/cart.js, remove the <<<<<<< / ======= / >>>>>>> markers, keep the correct code, then continue:

git add src/cart.js
git rebase --continue

Output:

Successfully rebased and updated refs/heads/feature/checkout-flow.

Only that one commit needed a manual fix; any remaining commits in the todo list apply automatically afterward.

How It Works Step by Step

When you run git rebase origin/main on feature/login-page, Git performs roughly these steps internally:

  • It finds the merge base — the most recent commit both branches share.
  • It builds a list (the rebase “todo list”) of every commit reachable from feature/login-page but not from that merge base — the commits unique to your branch.
  • It moves HEAD to the tip of origin/main (a detached-HEAD-like state used internally for the operation).
  • For each commit in the todo list, in order, Git computes the diff that commit introduced, applies that diff to the current working tree and index, and writes a brand-new commit object: a new tree reflecting the change, a new parent pointer (the previous replayed commit), and therefore a new SHA-1.
  • If a diff can’t be applied cleanly, the rebase pauses with conflict markers in the affected files, waiting for git add and git rebase --continue (or --skip/--abort).
  • Once every commit has been replayed, Git moves the feature/login-page branch pointer to the last new commit, and reattaches HEAD to the branch.
  • The original commits are no longer reachable from any branch, but they aren’t erased — they’re still recoverable via git reflog until garbage collection eventually cleans them up.

Common Mistakes

Mistake 1: Rebasing a branch other people have already pulled

git switch main
git rebase -i HEAD~5
git push --force origin main

Wrong because: main is shared. The moment it was pushed the first time, teammates fetched and based their own branches on those commits. Rewriting and force-pushing it replaces the commits everyone else already has, so their next git pull either creates a confusing pile of duplicate commits or fails outright.

Fix: never rebase a branch once it’s shared. If you haven’t pushed the rebase yet, undo it:

git rebase --abort

If you need to integrate feature/login-page into main, merge instead:

git switch main
git merge feature/login-page
git push origin main

Mistake 2: Force-pushing with bare --force after a legitimate local rebase

git push --force origin feature/login-page

Wrong because: even on a branch you own, someone else may have pushed a commit to it in the meantime (a teammate, a CI bot, or you from another machine). Bare --force overwrites the remote unconditionally, silently discarding that commit.

Fix: use --force-with-lease, which refuses to push if the remote has moved since you last fetched it:

git fetch origin
git push --force-with-lease origin feature/login-page

Mistake 3: Continuing a rebase without fully resolving a conflict

After a conflict, it’s easy to stage a file that still contains leftover markers:

<<<<<<< HEAD
const DISCOUNT_RATE = 0.15;
=======
const DISCOUNT_RATE = 0.10;
>>>>>>> 4e1a2f3 (feat: apply discount before tax)

Wrong because: git add followed by git rebase --continue doesn’t check that the markers are gone — it just commits whatever is in the file, which is now broken source code.

Fix: always inspect the file (or run git diff --check, which flags leftover conflict markers) before staging and continuing.

Best Practices

  • Treat rebase as a tool for cleaning up commits before they’re shared, not for changing commits other people already have.
  • Never rebase main, develop, or any branch other people branch from or have already pulled.
  • When you must force-push after a rebase, use git push --force-with-lease instead of bare --force.
  • Talk to your team before rewriting any branch more than one person touches, even if you think it’s safe.
  • Prefer git merge for integrating long-lived shared branches; reserve rebase for private feature branches.
  • Rebase onto the latest main early and often while a feature branch is still private — small, frequent rebases produce far fewer conflicts than one big one at the end.
  • Before a large interactive rebase, remember you can always recover the previous state with git reflog and git reset if something goes wrong — but confirm this before you push, not after.
  • If a rebase is going badly, run git rebase --abort rather than fighting through conflicts you don’t understand.

Practice Exercises

  • Create a new local branch, make four small commits with messages like fix: typo and wip, then use git rebase -i HEAD~4 to squash them into a single well-written commit before it would ever be pushed.
  • Set up two local clones of the same repository to play “you” and “a teammate.” Push a branch from one, have the other clone fetch and build a commit on top of it, then go back to the first clone, rebase and force-push the same branch. Fetch again from the second clone and observe the duplicated/diverged commits in git log --oneline --graph. Work out how you’d communicate and recover from this in a real team.
  • Create a rebase conflict on purpose: on main, edit line 1 of a file and commit; on a branch created before that commit, edit the same line differently and commit. Rebase the branch onto main, resolve the conflict, and finish with git rebase --continue.

Summary

  • Rebase replays your branch’s unique commits onto a new base, creating brand-new commit objects with new SHA-1s — it doesn’t edit the originals in place.
  • Because the hash changes, a rebased commit and its original are treated by Git as two unrelated commits with similar content, not the same commit “moved.”
  • The golden rule: never rebase commits that have already been pushed and might have been pulled or built on by someone else.
  • Rebasing local, unpushed, or purely personal branches is safe and is the best way to keep history clean before opening a pull request.
  • If you must force-push after a legitimate rebase, use --force-with-lease rather than bare --force to avoid silently overwriting someone else’s work.
  • When in doubt about whether a branch is shared, merge instead of rebase — merge never rewrites history.