The rule that prevents disasters
Never rebase commits that other people already pulled. Rebase rewrites SHAs. If main or a shared long-lived branch gets rebased, teammates must recover with reset and rebase gymnastics. We only rebase local feature branches that we own.
Merge commits preserve exact history and branching topology. Rebase replays your commits on top of an updated base for a straight-line story. Both are valid; mixing them without a team convention is what hurts.
We wrote this as a one-page CONTRIBUTING rule after a shared-branch rewrite burned about four engineer-hours in a single afternoon. Conventions beat cleverness here.
Daily sync: pull --rebase on feature branches
While building a feature, I keep my branch current with git fetch origin and git rebase origin/main, or git pull --rebase origin main. That surfaces conflicts early in small chunks instead of one monster merge at PR time.
We set pull.rebase true locally for feature work. Shared release branches still merge.
I rebase at least once a day on long features. Waiting a week means resolving the same conflict across many commits. Early rebases took me about 5 minutes; late ones took over an hour on a billing branch that touched shared types.
# Update feature branch onto latest main
git fetch origin
git switch feature/billing-retry
git rebase origin/main
# If conflicts: fix files, then
git add .
git rebase --continue
# Abort if it goes sideways
git rebase --abort
Cleaning the story before review
Before requesting review I often run interactive rebase onto origin/main and squash wip and fix typo commits into logical units. Reviewers get 3 commits that mean something instead of 27 noise commits. Force-push is required afterward — only on my feature branch, and we use --force-with-lease so I do not clobber unexpected remote commits.
I aim for commits that each leave tests green. Reviewers can bisect later if something regresses. A single giant squash of a week of work is fine for tiny PRs; for multi-day features I keep a short narrative.
git rebase -i origin/main
# mark commits as squash/fix/reword
git push --force-with-lease origin feature/billing-retry
Merging into main
On GitHub we usually Squash and merge for small PRs so main stays linear with one commit per feature. For larger multi-commit PRs that tell a deliberate story, we use merge commits or rebase-and-merge depending on the repo setting.
I avoid rebasing main onto a feature branch in public view. The integration direction is: update feature onto main, then merge feature into main via the PR UI.
Release branches freeze with merge-only. Hotfixes branch from the release tag, merge back to release and to main. Trying to rebase a hotfix that QA already tested is how you get works-on-my-machine checksum mismatches.
When merge is the better tool
Long-running release branches, hotfixes already shared, and any branch multiple people push to — merge. Also when you need to preserve the exact fact that two lines of development coexisted; compliance and audit trails sometimes care.
A teammate once rebased a shared develop branch to clean history and burned half a day of engineering time. We wrote the rule down after that.
- Private feature branch — rebase onto main is fine
- Shared branch — merge only
- Force push only with --force-with-lease on your branch
- Resolve conflicts during rebase carefully; abort when unsure
- Prefer early rebases over one giant conflict at the end
- Release and hotfix branches stay merge-only
Recovering when a rebase goes wrong
git rebase --abort is the first escape hatch if you are still mid-rebase. If you already finished and force-pushed something bad, git reflog usually still has the previous tip for a while. I restore with git reset --hard ORIG_HEAD immediately after a bad rebase, or HEAD@{1} from reflog when ORIG_HEAD is gone.
Tell anyone who pulled the bad tip to reset to the restored SHA. Communication matters more than the exact command once history diverged on a shared remote.
# Still in a rebase?
git rebase --abort
# Just finished a bad rebase, not pushed yet
git reset --hard ORIG_HEAD
# Already pushed; recover tip from reflog, then force-with-lease
git reflog
git reset --hard HEAD@{1}
git push --force-with-lease origin feature/billing-retry
What we tell new teammates on day one
Update your feature branch with rebase onto main. Open PRs from feature into main. Never force-push main. Never rebase a branch two people are committing to. When unsure, merge and ask in Slack — a merge commit is cheap compared with rewriting shared history.
We pair on the first interactive rebase. Squash, fixup, and reword are muscle memory after one guided session; without that session people either never clean history or force-push scared. CI must be green after rebase before review.
PR UI settings we standardize
Repos that prefer a linear main enable Allow squash merging and disable merge commits, or enable rebase-merging carefully. Mixed settings across repos confused people about whether they should clean locally.
Required status checks on main mean a force-pushed feature branch re-runs CI. That is good. I wait for green after force-with-lease before pinging reviewers again so they do not review an already-superseded SHA. Branch protection on main rejects force pushes; feature branches stay unprotected so rebase workflows work.
- Protect main against force push and direct commits
- Allow force-with-lease on personal feature branches
- Re-run CI after every rebase push
- Agree repo-wide on squash versus merge versus rebase-merge
Bisect loves clean history
When a production bug lands, git bisect across squash-merged main finds the PR quickly. Across a week of WIP commits on a long-lived branch, bisect lands on wip fix again and wastes time. That is a practical reason we clean feature history before merge, not an aesthetic one.
If you must keep fine-grained commits on main for blame archaeology, say so in the repo guide and use merge or rebase-merge consistently. Silent policy differences between teams sharing a monorepo cause the worst arguments. Default stance on our team: rebase privately, merge publicly through the PR button, recover with reflog, and optimize for bisectable main.
Conflict survival tips
During rebase, conflicted files are your commit applied onto newer main. Read both sides. Run tests after continue. If the same conflict repeats across commits, consider squashing first so you fix it once.
For binary files or generated lockfile hell, sometimes aborting, merging main into the feature branch once, and fixing lockfiles in a single merge commit is less painful than rebasing 15 lockfile bumps. Pragmatism beats purity.
I keep a sticky note of files that always conflict such as generated GraphQL types and lockfiles. For those, I regenerate from source after resolving rather than hand-merging thousands of lines. Bisect loves clean history on main — that is a practical reason we clean feature history before merge, not an aesthetic one.
Key takeaways
- Rebase private feature branches; never rewrite shared history.
- Pull with rebase to catch conflicts early while commits are fresh.
- Interactive rebase before review to remove WIP noise.
- Use --force-with-lease, not bare --force, when updating a feature remote.
- Choose squash, merge, or rebase-merge at PR time based on the story you want on main.
- Know rebase --abort and reflog recovery before you need them under pressure.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.