Your push bounces because the remote moved on without you. Here is how to catch up cleanly, when to rebase versus merge, and when a force-push is actually safe.
You finish a change, run git push, and Git slaps you down:
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'origin'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
Nothing is broken. This is Git protecting you. The remote branch has commits your local branch has never seen, and Git refuses to overwrite them with your version. The fix is to bring those commits into your history first, then push. The interesting part is how you bring them in, and the one case where you're allowed to shove your history over the top.
A fast-forward push is the easy case: your local branch is the remote branch plus a few extra commits on top. Git can move the remote pointer forward in a straight line, no history rewriting, no merging. Everyone's happy.
Non-fast-forward means the histories have split. Someone pushed to origin/main after you last pulled. Now your branch and the remote branch share a common ancestor but each has commits the other lacks. Git can't move the pointer in a straight line anymore, so it stops.
You'll see this wearing a few different hats:
Updates were rejected because the remote contains work that you do not have locallyUpdates were rejected because the tip of your current branch is behind its remote counterparthint: (e.g., 'git pull ...') before pushing againfetch firstSame root cause every time. The remote moved. You didn't.
Before you pull anything, see what you're dealing with.
git fetch origin
git log --oneline --graph HEAD origin/main
fetch updates your view of the remote without touching your working branch. The graph shows you the split: your commits on one line, theirs on another, a shared ancestor below. Now you decide how to reconcile them.
Two ways to integrate remote work. They produce different histories, and the difference matters more than most people admit.
git pull (merge) fetches the remote commits and ties the two lines back together with a merge commit.
git pull origin main
git push origin main
Your history keeps a visible fork-and-join. It's honest about what happened: two people worked in parallel and their work was joined. The downside is a log full of Merge branch 'main' of origin commits that carry no real information.
git pull --rebase replays your local commits on top of the remote ones, one at a time, as if you'd written them after everyone else.
git pull --rebase origin main
git push origin main
The result is a straight line. No merge bubble, no noise. This is my default for the everyday "I have two small commits, someone else pushed while I was working" situation. The catch: rebasing rewrites your local commit hashes, so never rebase commits you've already pushed and shared. On unpushed local work it's completely safe.
If you want rebase as the standing behavior for pulls, set it once:
git config --global pull.rebase true
Either approach can stop on a conflict when your changes and theirs touched the same lines. The workflow is the same idea, slightly different verbs.
With a merge pull, fix the marked files, then:
git add path/to/file
git commit
With a rebase pull, fix the files, then continue the replay:
git add path/to/file
git rebase --continue
Rebase stops at each conflicting commit, so you may resolve, continue, resolve, continue a few times. If it turns into a mess and you want out, git rebase --abort puts you back exactly where you started, no harm done. That escape hatch is why I don't fear rebase.
Once the pull is clean, git push fast-forwards and you're done.
Sometimes you want your version to win. You cleaned up a feature branch with an interactive rebase, squashed some noise, amended a commit message. Now your local history and the remote history genuinely disagree, and pulling would just re-merge the junk you deleted. This is the legitimate home of force-push.
Never reach for plain --force:
git push --force origin my-feature # don't
Plain force overwrites the remote no matter what's there. If a teammate pushed to that branch in the last ten minutes, you just deleted their commits and won't know until they ask where their work went.
Use --force-with-lease instead:
git push --force-with-lease origin my-feature
--force-with-lease only overwrites the remote if it still points where you last saw it. If someone pushed in the meantime, the lease is broken and Git rejects the push, handing you back the exact same rejected message from the top of this post. That rejection is the feature. It means you're about to clobber someone, and now you know to fetch and look first.
The rule I hold without exception: force-push only branches that are yours. Your feature branch, your experiment, a PR branch nobody else builds on. Never main, never a shared release branch, never a branch three people are cutting work from.
On GitHub, GitLab, or Bitbucket, the branches that matter are usually protected, and force-push is disabled server-side. Even --force-with-lease bounces:
remote: error: GH006: Protected branch update failed for refs/heads/main.
This is the platform saving you from yourself. The correct move on a protected branch is never to force anything. Pull with rebase, resolve, push normally, or open a pull request and let the merge happen through review. If you find yourself wanting to force-push main, stop. That urge is the bug.
Ninety percent of the time this error means one thing: fetch, then git pull --rebase, resolve any conflict, push. Straight-line history, no drama. Reach for a merge pull only when you actually want the fork recorded, or when the branch policy forbids rewriting. Force-push lives on your own branches and nowhere else, and when you do it, it's --force-with-lease every single time so Git can still stop you before you overwrite someone's afternoon. For the wider set of Git failures that land on you mid-sprint, our Git troubleshooting guide covers the rest.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Both promise to find your slow query at 3am. One bills by data ingested, the other by host-hour. Here's how that shakes out in a real ops budget.
A shared API key between two internal services proves nothing about who is calling. mTLS makes every service present a cryptographic identity instead.
Explore more articles in this category
The IaC landscape fractured after the Terraform license change. This is the map to what each tool is actually best at, and how to choose without regret.
Terragrunt keeps large Terraform setups DRY and orchestrated, but small teams often pay its learning curve for little return.
A practical comparison of Kubernetes-native continuous reconciliation against the classic CLI-driven, state-file IaC model for platform teams.
Evergreen posts worth revisiting.