Mert Özen Further With Every Line
DevOps Git & GitHub — #20

Git rebase: Rewriting History and Pushing It Safely

Mert Özen Nov 6, 2026 10 min 2 views
Git rebase: Rewriting History and Pushing It Safely

We compare merge and rebase in the same scenario: why rebase rewrites commits with new hashes, conflicts during a rebase and the flipped HEAD, pull --rebase, the golden rule, pushing a rebased branch with --force-with-lease and --force-if-includes, and --update-refs for stacked branches.

Version note: The commands in this post were tested with Git 2.54; --force-if-includes has been around since Git 2.30 and --update-refs since Git 2.38.

Guess First

Ada made two commits on the feature/search branch. Meanwhile Alan pushed a commit to main. In the examples we use a local bare repo (../bookstore.git) as the remote; it stands in for the repo on GitHub.

$ git fetch
From ../bookstore
   0fd0ee3..4bd3174  main       -> origin/main
$ git log --oneline --graph --all
* 4bd3174 (origin/main, origin/HEAD) Add price formatter
| * 0ad0a2b (HEAD -> feature/search) Document search in README
| * 48cdec9 Add search by title
|/  
* 0fd0ee3 (main) Add .gitignore
* eaa08db Add cart total
* e8b2239 Add book list
* e928043 Add README

Ada is about to run git rebase origin/main. When the command finishes, where will commit 48cdec9 be? Inside feature/search, somewhere else, or nowhere?

Merge or Rebase?

When two branches have diverged there are two ways to bring them together. Had you merged in the same situation, you would see this:

$ git merge origin/main
Merge made by the 'ort' strategy.
 src/format.js | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 src/format.js
$ git log --oneline --graph
*   157568a (HEAD -> feature/search) Merge remote-tracking branch 'origin/main' into feature/search
|\  
| * 4bd3174 (origin/main, origin/HEAD) Add price formatter
* | 0ad0a2b Document search in README
* | 48cdec9 Add search by title
|/  
* 0fd0ee3 (main) Add .gitignore
...

Merge keeps history exactly as it happened and ties the two lines together with a merge commit. No commit changes. The price is a graph that gets a little more braided with every sync. If you update your feature branch from main every day, a two-week task collects ten "Merge remote-tracking branch" commits.

Rebase does this instead:

$ git rebase origin/main
Successfully rebased and updated refs/heads/feature/search.
$ git log --oneline --graph --all
* d2f2883 (HEAD -> feature/search) Document search in README
* 1f5995b Add search by title
* 4bd3174 (origin/main, origin/HEAD) Add price formatter
* 0fd0ee3 (main) Add .gitignore
* eaa08db Add cart total
* e8b2239 Add book list
* e928043 Add README

History is a straight line. As if Ada had started her work after Alan's commit.

Rebase Rewrites Commits

The answer to the guessing question is in the graph: 48cdec9 is gone, and 1f5995b sits in its place. Rebase does not move commits; it cherry-picks each one onto the new base, one at a time. As you saw in the previous post, a new parent means a new hash. Messages, content and author stay the same, but to Git these are brand-new commits.

The old commits were not deleted; no branch points at them, so they don't show up in the graph, and they stay in the reflog for a while. We will see how to get them back in the reflog post. For now, keep one sentence in mind: rebase rewrites history.

An analogy: merging is building a junction where two roads meet. Rebasing is tearing up your own road and laying it again from where the main road ends. The road leads to the same place, but the asphalt is new.

Conflicts During a Rebase

The next day Alan pushed another commit to main that changes books.js. Ada had added her own function to the same file, in the same spot.

$ git fetch
From ../bookstore
   4bd3174..2ae4532  main       -> origin/main
$ git rebase origin/main
Auto-merging src/books.js
CONFLICT (content): Merge conflict in src/books.js
error: could not apply 9e8660d... Add findById helper
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 9e8660d... # Add findById helper
$ git status
interactive rebase in progress; onto 2ae4532
Last commands done (3 commands done):
   pick d2f2883 # Document search in README
   pick 9e8660d # Add findById helper
  (see more in file .git/rebase-merge/done)
No commands remaining.
You are currently rebasing branch 'feature/search' on '2ae4532'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
	both modified:   src/books.js

no changes added to commit (use "git add" and/or "git commit -a")

Status says "interactive rebase" even though you didn't pass -i; a plain rebase runs on the same machinery as an interactive one, so don't let that confuse you. The lines that matter: the first two of three commits applied cleanly, and we stopped at the third. In a merge you resolve a conflict once; in a rebase conflicts come commit by commit. On a five-commit branch you might meet the same file five times.

Looking at the file shows one more trap:

$ cat src/books.js
const books = [
  { id: 1, title: "Clean Code", price: 30 },
  { id: 2, title: "The Pragmatic Programmer", price: 35 },
];

<<<<<<< HEAD
function count() {
  return books.length;
}

module.exports = { books, count };
=======
function findById(id) {
  return books.find((b) => b.id === id);
}

module.exports = { books, findById };
>>>>>>> 9e8660d (Add findById helper)

In a merge, HEAD was your branch. In a rebase the roles swap: HEAD is the new base, meaning main plus the commits applied so far. The lower side is your own commit being applied. During a rebase the words "ours" and "theirs" mean the opposite; read the two sides by their content, not their labels.

Both have to stay here: keep both functions and make the export line { books, count, findById }. Then:

$ git add src/books.js
$ git rebase --continue
[detached HEAD da020ef] Add findById helper
 1 file changed, 5 insertions(+), 1 deletion(-)
Successfully rebased and updated refs/heads/feature/search.

--continue opens an editor for the commit message; save and close. --skip drops that commit entirely, and --abort returns the branch to its state before the rebase. The detached HEAD in the output is normal too: while the rebase runs, Git produces the commits on a nameless HEAD and moves the branch to the new tip at the very end.

pull --rebase

The same idea helps with everyday git pull. Ada made a small commit on main, and in the meantime Alan pushed. A plain pull would create a merge commit here (or give you the "divergent branches" error from the pull post). --rebase moves Ada's local commit on top of what came in:

$ git pull --rebase
From ../bookstore
   2ae4532..6d97756  main       -> origin/main
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph -5
* 70bbd7c (HEAD -> main) Ignore log files
* 6d97756 (origin/main, origin/HEAD) Comment cart total
* 2ae4532 Add book count helper
* 4bd3174 Add price formatter
* 0fd0ee3 Add .gitignore
$ git push
To ../bookstore.git
   6d97756..70bbd7c  main -> main

If you don't want to type it every time, make it the default with git config set pull.rebase true (the old form is git config pull.rebase true). The only commit rewritten here is your local one that nobody has seen yet, so this is safe.

The Golden Rule: Don't Rebase Shared History

Rebase has one rule, and it is absolute: don't rebase commits other people have built work on. If you rebase a branch everybody pulls, like main, and push it, the commits in everyone's clone suddenly turn into commits that "no longer exist". On their next pull they see the same changes twice, and figuring out who lost what takes hours.

In practice: you can rebase your own feature branch before you open a PR, or while you are the only one working on the PR. But if a teammate also pushes to the same branch, tell them before you rebase. And if you forgot to tell them?

Pushing a Rebased Branch: --force-with-lease

Ada had already pushed feature/search. Meanwhile Alan, without telling Ada, added a test commit to the same branch. Ada rebased onto the current main and tried to push:

$ git switch feature/search
Switched to branch 'feature/search'
Your branch is up to date with 'origin/feature/search'.
$ git rebase main
Successfully rebased and updated refs/heads/feature/search.
$ git push
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (fetch first)
error: failed to push some refs to '../bookstore.git'
...

Note the "up to date" line: Git only knows what it saw at the last fetch. After a rebase a push is always rejected, because the remote branch is no longer an ancestor of yours. The first thing that comes to mind here is git push --force, and that would have silently deleted Alan's commit. Instead:

$ git push --force-with-lease
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (stale info)
error: failed to push some refs to '../bookstore.git'

--force-with-lease says: "Overwrite the remote branch if it is still at the commit I last saw; otherwise don't." Ada's origin/feature/search was out of date, there was a new commit on the remote, and the push stopped.

But this protection has a hole. If your editor or IDE runs git fetch in the background, or you ran fetch out of habit, "the commit I last saw" gets updated:

$ git fetch
From ../bookstore
   da020ef..9800da7  feature/search -> origin/feature/search
$ git push --force-with-lease --dry-run
To ../bookstore.git
 + 9800da7...b2e47ca feature/search -> feature/search (forced update)

--dry-run sends nothing, but tells you what would happen: you were about to overwrite Alan's commit without ever looking at it. --force-if-includes, added in Git 2.30, closes exactly this hole. It requires that the tip of the remote branch has actually made it into your local history:

$ git push --force-with-lease --force-if-includes
To ../bookstore.git
 ! [rejected]        feature/search -> feature/search (remote ref updated since checkout)
error: failed to push some refs to '../bookstore.git'
...

Now Ada can look at what she missed. The idea of equivalent commits from the previous post pays off here; --cherry-pick drops commits with the same patch from the list:

$ git log --oneline --cherry-pick --right-only HEAD...origin/feature/search
9800da7 (origin/feature/search) Add search tests
$ git reset --hard origin/feature/search
HEAD is now at 9800da7 Add search tests
$ git rebase main
Successfully rebased and updated refs/heads/feature/search.
$ git push --force-with-lease --force-if-includes
To ../bookstore.git
 + 9800da7...b95344d feature/search -> feature/search (forced update)

The only new commit is Alan's. Ada took the remote version as her starting point, repeated the rebase on top of it, and this time the push went through. She let go of her earlier rebase result, because the same commits were recreated by the new rebase. Instead of typing both options every time, you can make --force-if-includes the default with git config set push.useForceIfIncludes true; then typing --force-with-lease is enough.

Stacked Branches: --update-refs

When you split a big task into pieces, branches end up stacked: feature/search-ui sits on top of feature/search. When main moves and you rebase the top branch, the one below stays on the old commits. --update-refs from Git 2.38 moves the branches in between along with it:

$ git switch feature/search-ui
Switched to branch 'feature/search-ui'
$ git rebase --update-refs main
Successfully rebased and updated refs/heads/feature/search-ui.
Updated the following refs with --update-refs:
	refs/heads/feature/search
$ git log --oneline --graph main~1..feature/search-ui
* 8c4e525 (HEAD -> feature/search-ui) Add search box markup
* 132def6 (feature/search) Add search tests
* 137f667 Add findById helper
* 4495cc6 Document search in README
* dc775e3 Add search by title
* 6440e17 (origin/main, origin/HEAD, main) Rename project in README

feature/search moved onto the new commits too. If you use this all the time, leave it on with git config set rebase.updateRefs true. Don't forget to push both branches separately afterwards.

Common Pitfalls

Rebasing main. The branch being rebased should be your feature branch; the command says "move the branch you are on onto the base you give". If you type git rebase feature/search while on main, you do the opposite.

Taking HEAD for your own code during a conflict. In a rebase, HEAD is the base. Pick the wrong side and you silently lose your own change.

Leaving a rebase half done. If you resolve a conflict, type git commit and walk away, the rebase is still running. If git status says "rebase in progress", you are not done: --continue or --abort.

Using --force out of habit. For the push after a rebase, use --force-with-lease --force-if-includes. Even in CI, say on an automatically updated branch, a plain --force overwrites someone else's work.

Going a long time without rebasing. Rebasing a branch that has been away from main for two weeks means a conflict at every commit. Small, frequent rebases hurt far less than rare, big ones.

A Small Experiment

★☆☆ In your bookstore repo, create a branch from main, make two commits, and make a commit on main that touches a different file. Compare the hashes with git log --oneline before and after the rebase. Which ones changed, which ones stayed the same?

★★☆ Change the same line differently on two branches and combine them first with a merge, then (after going back with reset) with a rebase. In both cases, note which code sits on the HEAD side.

★★★ Set up two clones and one bare repo. Push a branch from one, then add a commit to the same branch from the other and push it. Rebase in the first one and try, in order, --force-with-lease, git fetch, --force-with-lease --dry-run and --force-with-lease --force-if-includes. For each step, write one sentence on why you got that result.

In this post we used rebase to move a branch as it is. But while it moves commits, rebase also lets you change them: reorder them, combine them, fix their messages, drop one from the middle. In the next post we tidy up history before a PR with git rebase -i, fixup commits and squash.