You finish up some work, run git push, and instead of the usual quiet success, you get this:

$ git push
To github.com:you/project.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:you/project.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.

Nothing about your code changed, nothing looks broken locally, and the message reads like Git is just refusing to cooperate. It isn’t. Git is telling you something specific and true: the remote branch has commits your local branch doesn’t have — probably a teammate pushed since you last pulled, or you pushed from another machine — and it won’t let your push proceed until you deal with that.

What’s actually happening

A normal git push is only allowed to move a branch forward — Git calls this a fast-forward. Your local branch has to already contain every commit that’s currently on the remote branch, plus whatever new commits you’re adding on top. That’s the whole rule.

The rejection means that condition isn’t met: origin/main has at least one commit your local main has never seen. If Git let the push through anyway, the only way to make the remote branch match what you’re sending is to replace its history — which would silently throw away whatever commit the remote has that you don’t. Git isn’t being cautious for no reason here. This exact check is what stops you from erasing a commit you didn’t even know existed, just because you happened to push first.

The fix, step by step

1. Confirm what you’re actually missing. Fetch first, without touching your working files, then look at what’s there:

git fetch
git log HEAD..origin/main --oneline

That second command lists commits that exist on the remote but not in your local branch — exactly what the rejection is protecting.

2. Integrate those commits into your branch. Two ways to do this, and the difference matters:

git pull

Fetches and merges the remote changes into your branch, creating a merge commit if both sides have new work. Your local commits stay exactly as they were, just combined with the remote ones.

git pull --rebase

Fetches, then replays your local commits on top of the remote ones instead of merging. History stays linear — no merge commit — but your commits get new hashes, since they’re technically new commits built on a different base.

Either is fine for most day-to-day work; which one your team prefers is a style choice, not a correctness one.

3. Resolve any conflicts that come up. If you and the remote both touched the same lines, Git will stop and ask you to resolve them, the same as any merge — this is a separate, normal step, not a sign that something’s gone wrong with the push itself.

4. Push again. Your branch now contains every commit the remote has, plus yours — a clean fast-forward, and the push goes through without needing anything special.

Two mistakes worth knowing about ahead of time

Reaching for git push --force to make the rejection go away. Force-pushing tells Git “don’t check any of that, just make the remote match my local branch exactly” — which means whatever commit the remote had that you didn’t have is now gone from the branch, not merged in, not preserved anywhere obvious. On a shared branch, that’s someone else’s work disappearing without warning. There are legitimate reasons to force-push — cleaning up your own commits on a branch nobody else has pulled yet, for instance — but “the normal push got rejected” isn’t one of them. If you ever do need to force-push deliberately, use git push --force-with-lease instead of plain --force: it still refuses if the remote has commits you haven’t fetched, so you can’t blow away work you haven’t even looked at by accident.

Assuming the hint’s “fetch first” means fetching alone fixes it. git fetch only updates your local copy of the remote branch (origin/main) — it doesn’t touch your actual working branch or merge anything in. Run just git fetch and then git push again, and you’ll get the exact same rejection, because your branch still hasn’t changed. You need the integration step — git pull, or git merge origin/main / git rebase origin/main after fetching — not just the fetch.

A habit that prevents the confusion entirely

On any branch other people are also pushing to, pull before you push, not just after you’re rejected — especially if it’s been more than a few minutes since you last synced. Treat “it’s been a while” as the signal to check, the same way you’d check for new messages before replying to a thread. The rejection itself is never the actual problem; it’s Git catching you a step behind where the branch actually is. Pulling first just means you find that out before you’ve already written your push command, instead of after.