Git is refusing the push because accepting it would delete commits that exist on the remote and not in your branch. That refusal is a safety feature, and it is almost always protecting someone else's work.
The instinct to reach for --force is what turns a two-minute inconvenience into an afternoon of recovery for a colleague.
See exactly what you would be discarding
Before doing anything, find out what is actually on the other side:
git fetch origin
git log --oneline --graph --left-right HEAD...origin/main
< a3f2c1d Add retry to payment client ← yours only
< 7b8c9d0 Fix typo in README
> f4e5d6c Bump axios to 1.7.4 ← theirs only
> 2a1b3c4 Add rate limiting to /api/login
< is yours, > is theirs. Those two > commits are what a force-push would remove.
git rev-list --count HEAD..origin/main # commits you are missing
git rev-list --count origin/main..HEAD # commits you would add
If the first number is zero, something else is wrong. See the stale-copy section below.
The normal fix: integrate, then push
Two ways, and the choice affects your history rather than the outcome.
Rebase replays your commits on top of theirs, keeping history linear:
git pull --rebase origin main
git push origin main
Merge creates a merge commit joining the two lines:
git pull origin main
git push origin main
Rebase is the usual choice for your own feature work, because the result reads as though you started from current code. Merge is correct when others are working on the same branch, since rebasing shared commits strands them.
Setting rebase as the default stops a busy repository filling with "Merge branch 'main' of…" commits:
git config --global pull.rebase true
If the rebase hits conflicts, resolve each, git add the files, and git rebase --continue. git rebase --abort returns you to exactly where you started, at any point.
When you legitimately need to overwrite
There is a real case: you rebased or amended your own feature branch, so your local history is intentionally different and the remote copy is the stale one.
git push --force-with-lease origin feature/user-search
Use --force-with-lease, never --force. The difference is what happens when someone else has pushed since you last fetched:
--force | --force-with-lease | |
|---|---|---|
| Remote unchanged since your fetch | Pushes | Pushes |
| Someone else pushed | Silently destroys their commits | Refused |
! [rejected] feature/user-search -> feature/user-search (stale info)
That rejection is the lease working. Fetch, look at what arrived, and decide deliberately.
Make it the default so the safe form is the one your fingers learn:
git config --global alias.pushf 'push --force-with-lease'
Never force-push a shared branch. On main, or any branch others have pulled, everyone who has it is stranded on commits that no longer exist, and their next pull produces a mess that takes real time to untangle. Protect those branches at the host so the question cannot arise.
"fetch first" is the same thing, earlier
! [rejected] main -> main (fetch first)
This variant means your local origin/main is out of date. Git can see the remote has moved but has not downloaded the commits yet. git fetch then proceeds as above. Functionally it is the same situation as non-fast-forward.
A stale local copy of the remote
Occasionally the push is rejected while git log origin/main..HEAD shows nothing missing. That means your remote-tracking branch is wrong:
git fetch --prune origin
git remote -v
Two causes. Your copy of origin/main is stale, which fetch fixes. Or the branch was rewritten or deleted and recreated upstream, in which case your origin/main points at commits that no longer exist there.
git config --global fetch.prune true
That keeps deleted upstream branches from lingering locally, which removes a whole class of confusion.
Recovering commits a force-push destroyed
If someone has already forced over your work, it is very likely recoverable, locally.
git reflog
a3f2c1d HEAD@{0}: pull: Fast-forward
7b8c9d0 HEAD@{1}: commit: Add retry to payment client
f4e5d6c HEAD@{2}: commit: Fix typo in README
git switch -c recovered 7b8c9d0
The reflog records every position HEAD has held on your machine and keeps entries for around 90 days, so anything you committed is still reachable even when no branch points at it.
Two caveats. The reflog is local, so it only helps on a machine that had the commits. And work that was never committed is genuinely gone, which is the strongest argument for committing early, even messily.
On the server side, GitHub and GitLab retain unreachable commits for a period and their reflog or activity API can sometimes recover them, though it is far less convenient than having a local copy.
Preventing it
Fetch before you start work, not when you are ready to push. Discovering divergence after an hour of work is the expensive version.
Keep feature branches short. The longer a branch lives, the more main moves and the worse every integration gets.
Protect main with a rule requiring pull requests and forbidding force-push, so the dangerous option is unavailable rather than merely discouraged.
Set pull.rebase true and fetch.prune true globally. Both remove recurring friction.
A checklist
git fetch origin. Get the current state.git log --oneline --left-right HEAD...origin/main. See both sides.- Their commits are wanted →
git pull --rebasethen push. - Your own branch, deliberately rewritten →
git push --force-with-lease. --force-with-leaserejected → someone pushed; fetch and look again.- Never force-push
mainor any shared branch. - Nothing appears missing →
git fetch --prune; your remote copy is stale. - Work already overwritten →
git reflogand branch from the lost commit.
Frequently Asked Questions
What does "non-fast-forward" mean in Git?
A fast-forward is when the remote branch's tip is an ancestor of yours, so the remote pointer can simply move forward and nothing is lost. Non-fast-forward means that is not true: the remote has commits your branch does not contain, so accepting your push would remove them. Git refuses rather than silently discarding history. The fix is to bring those commits into your branch with git pull --rebase or git pull, then push.
Should I use git push --force to fix this?
Almost never, and not as a first response. Force-pushing overwrites the remote branch, deleting the commits Git was protecting, which on a shared branch strands everyone who has pulled it. Integrate the remote changes instead. The legitimate case is your own feature branch after an intentional rebase or amend, and even then use --force-with-lease, which refuses if someone else has pushed since your last fetch.
What is the difference between --force and --force-with-lease?
--force overwrites the remote branch unconditionally. --force-with-lease first checks that the remote is still where you last saw it, and refuses with "stale info" if someone else has pushed in the meantime. So the destructive outcome, silently deleting a colleague's commits, becomes an error message instead. There is no situation where plain --force is preferable; alias --force-with-lease so it is what you type by default.
What is the difference between "non-fast-forward" and "fetch first"?
Effectively nothing. "fetch first" appears when your local copy of the remote branch is out of date, so Git knows the remote has moved but has not downloaded the new commits. "non-fast-forward" appears when it has them and can see the divergence. Both mean the remote holds commits you do not, and both are resolved the same way: fetch, integrate with rebase or merge, then push.
My push is rejected but git log shows nothing missing. Why?
Your remote-tracking branch is stale, so you are comparing against an old snapshot of the remote. Run git fetch --prune origin and check again. The other possibility is that the upstream branch was rewritten or deleted and recreated, leaving your origin/main pointing at commits that no longer exist there. Setting fetch.prune true globally keeps this class of confusion from recurring.
Can I recover commits that someone force-pushed over?
Usually, if you have a machine that had them. git reflog lists every position HEAD has held locally and retains entries for about 90 days, so git switch -c recovered <sha> brings the work back even though no branch points at it any more. The reflog is local, so it only helps where the commits once existed. Uncommitted work is not recoverable at all, which is the practical argument for committing early and often, even on messy work in progress.