Your feature branch is two commits old, and main has moved on by one commit. You need main’s changes in your branch before you open the pull request. You can git merge main or git rebase main. Both leave your branch with the same files. They leave very different history, and one of them can cause real trouble for your teammates.
“Merge or rebase?” is a favourite interview question because people have strong opinions about it. The interviewer is less interested in your preference than in whether you understand what each command does to commits, and the one rule that keeps rebasing safe.
The starting point
In a test repository, the history looks like this:
$ git log --oneline --graph --all
* 911b4c0 Add search pagination
* 91f408f Add search endpoint
| * 1ae890a Fix billing rounding
|/
* 1322125 Initialfeature/search has two commits. main has a billing fix the feature branch doesn’t have yet.
What merge does
$ git switch feature/search
$ git merge --no-edit main
Merge made by the 'ort' strategy.
billing.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 billing.txt
$ git log --oneline --graph
* 84905cc Merge branch 'main' into feature/search
|\
| * 1ae890a Fix billing rounding
* | 911b4c0 Add search pagination
* | 91f408f Add search endpoint
|/
* 1322125 InitialMerge creates one new commit with two parents. Your two existing commits keep their hashes, 911b4c0 and 91f408f, unchanged. Nothing anyone else has seen is altered, so merge is always safe on shared branches.
The cost is history shape. A long-running branch that merges main in every day ends up with a merge commit per day, and git log --graph becomes a braid that’s tedious to read. Those “Merge branch ‘main’ into feature/search” commits carry no information about the feature itself.
What rebase does
Starting again from the same point, on a copy of the branch:
$ git rebase main
Successfully rebased and updated refs/heads/feature/search-copy.
$ git log --oneline --graph
* f36ee2e Add search pagination
* a92e120 Add search endpoint
* 1ae890a Fix billing rounding
* 1322125 InitialThe history is a straight line, as if you’d started the feature after the billing fix. But look at the hashes. “Add search endpoint” was 91f408f and is now a92e120. Rebase doesn’t move commits. It replays each commit’s change on top of the new base and creates new commits. A commit’s hash covers its parent, so a new parent means a new hash, even with identical content, author and message. The old commits still exist (the reflog can find them) but your branch no longer points at them.
That’s the key fact for everything that follows. After a rebase, your branch and the copy on the server are different histories that happen to contain similar changes.
The rule: don’t rebase what others have built on
Rebasing commits that only exist on your machine is harmless. Nobody else has them. Rebasing commits you’ve already pushed to a branch other people use is the problem.
Here’s what that looks like. A shared feature/shared branch is on the server. Priya pulls it, adds “Add search filters” and pushes. Meanwhile you rebase your local copy onto main and push:
$ git push origin feature/shared
! [rejected] feature/shared -> feature/shared (non-fast-forward)
error: failed to push some refs to '../mr-remote.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.The rejection is Git protecting Priya’s commit. Your rebased branch doesn’t contain it, so accepting your push would discard it. The tempting response is git push --force, which overwrites the server’s branch with yours and deletes Priya’s commit from it. The next time Priya pulls, her history and the server’s disagree, and depending on what she does she’ll either reintroduce your old pre-rebase commits as duplicates or lose her own work. The related guide on failed to push some refs covers the error in more detail.
Force-with-lease, and the fetch that breaks it
git push --force-with-lease is the safer force push. It only overwrites the remote branch if it still points where your remote-tracking ref (origin/feature/shared) says it does. If someone pushed since you last looked, it refuses.
The catch: “since you last looked” means since your last fetch. Many editors and some shell prompts run git fetch in the background. In the test repository, a git fetch ran after Priya’s push and before the force push:
$ git fetch origin
$ git rebase main
$ git push --force-with-lease origin feature/shared
To ../mr-remote.git
+ 3fa1a9d...f36ee2e feature/shared -> feature/shared (forced update)The lease checked that the server matched origin/feature/shared, which it did, because the fetch had just updated it. Priya’s commit 3fa1a9d is gone from the server. You never looked at it; your tooling did.
Git 2.30 added --force-if-includes for this. It also checks that the remote tip is somewhere in your branch’s reflog, meaning you actually had it locally and chose to rewrite it. Repeating the scenario with both flags:
$ git push --force-with-lease --force-if-includes origin feature/shared
! [rejected] feature/shared -> feature/shared (remote ref updated since checkout)
error: failed to push some refs to '../mr-remote.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout.Setting git config --global push.useForceIfIncludes true makes it the default whenever you use --force-with-lease.
Choosing in practice
A workable team convention:
- Your own feature branch, before review or while only you push to it: rebase onto
mainas often as you like.git pull --rebase(orpull.rebase=true) keeps your local commits on top of what you fetch instead of creating merge commits for every pull. - After you’ve pushed and others are reviewing: rebasing is still common, since only you commit to it, but it rewrites the commits reviewers commented on. Some review tools handle this well, others lose comment context. Agree as a team.
- Branches several people commit to, and
mainitself: merge only. Never rewrite them. - Landing the pull request: this is a separate choice. “Merge commit” preserves the branch’s commits and records the join. “Squash and merge” produces one commit on
main. “Rebase and merge” replays the branch’s commits ontomainwithout a merge commit. Each is defensible; consistency matters more than which.
A misconception worth correcting if it comes up: rebase isn’t “cleaner” in a way that reduces conflicts. You resolve the same conflicts either way, and with rebase you may resolve them once per commit as each is replayed. Rebase gives a more readable log. Merge gives a more truthful one.
Interview practice
A colleague rebased the shared release/3.2 branch onto main to “clean it up,” then force-pushed. Three other developers have local copies of the old release/3.2, and one of them had pushed a hotfix commit to it an hour before the force-push. What has happened, and how do you recover?
Answer
The rebase created new commits for everything on release/3.2, with new hashes, and the force-push replaced the server’s branch with them. The hotfix commit pushed an hour earlier wasn’t in the colleague’s local branch (or was, if they’d fetched, which is exactly how --force-with-lease can still let this through), so it’s no longer on the server. The three developers’ local branches now contain the old commits, and if they pull normally Git will try to merge old and new histories, producing duplicate commits.
To recover, first find the old tip. The developer who pushed the hotfix has it, and anyone who fetched before the force-push has it in their reflog for origin/release/3.2, via git reflog show origin/release/3.2. Since the rebase was only cosmetic, the simplest fix is to restore the old branch: push the old tip back with a deliberate force-push, announce it, and have the colleague who rebased reset to it. Then verify the hotfix commit is on the branch with git branch -r --contains <hotfix-sha>. Going forward, protect release branches against force-pushes in the hosting settings, and set push.useForceIfIncludes so force-pushes check what you actually saw.