Git push: what to do when the remote refuses your commits
A git push that fails prints a wall of text, and the useful part is one word in the middle of it. Everything after that word is advice; everything before it is context. The single most common mistake at this point is reaching for --force because the push was refused, without first reading which of the several refusals actually happened. Two of them are fixed by a pull. One of them is a server policy that force will not move. One of them means the file is too large and no amount of retrying will change that.
Read the flag and the summary before changing anything
The output is a table, and the manual defines its columns. Each line takes the form of a flag, a summary, the local ref, the remote ref, and a reason in parentheses.
The flag is one character. A space means a successful fast-forward. A plus means a successful forced update. A minus means a ref was deleted. An asterisk means a new ref was created. An equals sign means the ref was already up to date. An exclamation mark means that ref was rejected or failed, and it is the only one that needs attention.
The summary is where the diagnosis lives. There are three distinct failure summaries, and they have nothing to do with each other.
| Summary | What happened | Where the fix is |
|---|---|---|
rejected |
Git did not even try to send. The update is not a fast-forward and no force was given | Local. Integrate the remote history first |
remote rejected |
The server received the update and refused it | Server policy, a hook, or the content of the commit |
remote failure |
The server did not confirm the update, often a transient network or server error | Retry, then check the service status |
Refs that are already up to date are hidden unless --porcelain or --verbose is passed, which is why a push can look like it did nothing when in fact there was nothing to do.
When the summary says rejected, history diverged
This is the case that a pull fixes, and understanding why makes the fix obvious rather than ritual.
An update is a fast-forward when the new commit is a descendant of the old one. In that case no history is lost: everything the old commit was built on is still in the ancestry of the new one. When two people start from the same commit and each build their own commit on top, neither is a descendant of the other. Accepting one would drop the other from the branch, so Git refuses by default. The manual states the reason plainly: the command does not allow a non fast-forward update in order to prevent loss of history.
There are two ways to make the update a fast-forward again. git pull fetches and creates a merge commit joining the two lines, and that merge is a descendant of both, so the next attempt is accepted. git pull --rebase instead replays the local commits on top of the fetched tip, producing a linear history whose last commit is also a descendant. Which one to use is a project convention rather than a correctness question, and the only wrong answer is switching between them at random within one branch.
There is a version of this that happens with nobody else involved. A commit is sent, then amended with git commit --amend, then sent again. The amend produced a different commit, so the second attempt is not a fast-forward either. The manual notes this case specifically, and it is the one legitimate place where overwriting is the intent rather than an accident.
When the summary says remote rejected, the server decided
Force does not help here, because the refusal came after the data arrived. The manual lists the usual causes: a hook on the remote side, or one of the receive side safety settings.
receive.denyCurrentBranch refuses an update to the branch that is currently checked out in a non bare repository, because the working tree there would no longer match the commit. receive.denyNonFastForwards refuses forced updates regardless of what the client asked for, which is how a shared repository is protected against rewriting. receive.denyDeletes and receive.denyDeleteCurrent cover branch deletion.
On hosted services, the same category covers a longer list. A protected branch rule refuses direct updates and requires a pull request. A required status check refuses until it passes. Secret scanning protection refuses a commit that contains what looks like a credential, and the fix is to remove the credential from history rather than from the latest commit, because the commit that contains it is the one being sent.
File size lives here too, and the numbers are published. GitHub warns on any file larger than 50 MiB and blocks any file larger than 100 MiB, with Git Large File Storage as the documented route for anything beyond that. Files added through the browser are capped lower, at 25 MiB. The same page recommends keeping a repository under 1 GB and strongly recommends staying under 5 GB. The details are on the GitHub documentation for large files. The trap in this one is that removing the file and committing again does not help. The oversized blob is still in the history being sent.
Forcing without destroying someone else's work
When the history really does need to be replaced, --force is the blunt instrument and rarely the right one. It disables the fast-forward check entirely and, as the manual warns, can cause the remote repository to lose commits.
--force-with-lease is the same operation with a condition attached. It updates the remote ref only if that ref still points where the local remote-tracking ref says it does. The manual describes it as taking a lease on the ref without locking it: if someone else sent a commit in the meantime, the lease is no longer valid and the operation fails instead of overwriting their work. For a rebase that needs republishing, this is the correct default.
There is a real caveat, and it is in the documentation. Used without an explicit expected value, --force-with-lease interacts badly with anything that runs git fetch in the background, which includes some editors and any scheduled fetch. A background fetch updates the remote-tracking ref, the lease then matches, and the protection is gone. The documented mitigations are to specify the expected value explicitly, in the refname:expect form, or to add --force-if-includes, which checks that the remote tip has actually been integrated locally by looking at the reflog of the local branch.
One more detail matters. --force applies to every ref in the transfer, so with push.default set to matching or with multiple destinations configured, it can overwrite refs other than the intended branch. Forcing one branch only is done with a plus in front of the refspec.
When the failure is authentication, not refusal
A third category of failure never produces a ref table at all, and it is worth separating because the fix is in a completely different place.
Over HTTPS, the symptom is a repeated credential prompt, or a message about authentication failing. GitHub's own documentation for macOS recommends either the GitHub CLI or Git Credential Manager for storing credentials over HTTPS, and notes that Git Credential Manager handles authentication on the account's behalf, including two-factor authentication, so no token has to be created and pasted by hand. It is installed as a cask through Homebrew, and on macOS it configures Git automatically with no extra config step.
The older route still works and is already present on most Macs. The git that ships with the Xcode Command Line Tools includes a credential helper that stores credentials in the login keychain, alongside the cache and plain file helpers. When a stale credential is the problem, the entry in Keychain Access is where it lives, and deleting it makes Git ask again.
Over SSH, the equivalent failure is a permission denied message naming the key. The documented alternative to HTTPS credentials is an SSH key registered with the account, which removes the prompt entirely.
One message in this category is routinely misread. A repository not found error, on a repository that plainly exists, usually means the credentials being presented belong to an account without access to it. Git cannot distinguish a private repository it may not see from one that does not exist, so it reports the latter. Checking which account the stored credential belongs to resolves this far faster than checking the URL for typos, and the same applies when two accounts are in use on one machine.
The no-upstream case, and what push.default decides
A different failure has no rejection at all. The command errors out before contacting anything, saying the current branch has no upstream branch.
When no refspec is given, Git consults push.default. The documented values are nothing, current, upstream, simple and matching, plus tracking as a deprecated synonym of upstream. simple has been the default since Git 2.0, and it sends the current branch to a branch of the same name, requiring a configured upstream in a centralized workflow. matching was the old default and sends every branch that exists on both sides, which is why an older machine can publish work nobody asked for.
The fix for a new branch is -u, spelled --set-upstream, which records the tracking reference while sending so that later bare commands work. For people who create branches constantly, push.autoSetupRemote set to true assumes --set-upstream when no upstream exists, and it takes effect with push.default set to simple, upstream or current.
Checking first instead of afterwards
Two options turn the operation into something predictable.
-n, spelled --dry-run, does everything except send the updates. It resolves the refspec, works out which refs would change, and prints the same table. On any transfer that feels uncertain, this costs one second and answers what is about to happen. --porcelain produces machine readable output and shows up to date refs as well, which makes it the right pair for a script.
--atomic asks the server for a single transaction: either every ref updates or none does. Without it, a transfer of several branches can half succeed, leaving a tag pointing at a commit that is not reachable from any branch on the server. If the server does not support atomic transactions, the command fails rather than silently falling back.
For reviewing content rather than refs, git diff @{u}.. shows what the local branch has that its upstream does not. Running that beforehand is what catches the debug statement and the local config file. A file manager with a built in terminal shortens that loop, because the folder being inspected and the shell running the command are the same place. How that compares with the usual arrangement of separate windows is covered in the comparison page, and the FAQ covers what it does not try to do.
What to change first
Set push.autoSetupRemote to true and stop fixing the no-upstream error by hand. Then replace --force with --force-with-lease --force-if-includes everywhere it appears in notes, aliases and scripts, because the two together refuse in exactly the case where force would have destroyed something. If the diff, the folder and the shell are currently three separate windows, Atriens puts them in one.
Frequently asked questions
What does "Updates were rejected because the remote contains work that you do not have locally" mean?
The remote branch has commits that the local branch does not, so the update would not be a fast-forward and would drop them. Run git pull to merge them, or git pull --rebase to replay local commits on top, then try again. Forcing here overwrites the other commits.
Is --force-with-lease always safe?
It is much safer than --force, but not unconditional. The documentation warns that used without an explicit expected value it can be defeated by anything running git fetch in the background, since that updates the remote-tracking ref the lease is checked against. Adding --force-if-includes closes that gap.
Why did it fail with "remote rejected" when the branch was up to date?
That summary means the server refused after receiving the update. Common causes are a hook on the remote, a protected branch rule, a receive side setting such as receive.denyCurrentBranch, secret scanning, or a file over the size limit. None of these are fixed by pulling or forcing.
How can the result be checked before anything is sent?
Run the same command with -n, or --dry-run. It resolves everything and prints the table of refs that would change without sending anything. Adding --porcelain shows refs that are already up to date as well, which makes the output usable from a script.
A large file was deleted and committed again, so why is it still blocked?
Because the oversized object is still in the history being sent. The size check looks at the objects in the commits, not at the final state of the tree. The file has to be removed from the commits that contain it, or tracked through Git Large File Storage.