How to fix Git non-fast-forward errors
Diverge two clones, fail git push with non-fast-forward, then fetch plus merge or git pull, and push. No rebase, no --force, no --web.
What are we building and why?
We are recovering a rejected git push after two clones diverge. GitHub refuses a non-fast-forward so you do not overwrite remote commits. We use a private scratch repo, two clones, a failed push on clone B, then git fetch plus merge or pull, then a successful push. You do not rebase or force.
GitHub's Dealing with non-fast-forward errors page is the map. A clean first push is How to Push Commits to GitHub. Catching up when you are only behind is How to Fetch and Pull from GitHub. Also How to Understand Git on GitHub. Authority: git-push, git-merge, git-pull.
When we ran this two-clone loop at ZeroLabs and ZeroShot Studio on 2 Sep 2026, clone B's git push origin main exited 1 in 0.4 seconds with [rejected] and non-fast-forward. Fetch plus merge created a 2-parent commit in 81 milliseconds. The follow-up push matched git ls-remote in 1.3 seconds. Without GIT_TERMINAL_PROMPT=0, a missing credential sat for 30 seconds. After the three prompt locks, hangs dropped to 0% in 8 runs. The trade-off is a private repo plus two clones until you delete them. I found agents treat [rejected] as a license to --force. Others copy --ff-only from the behind-only recipe and the merge dies.
"To prevent you from losing history, non-fast-forward updates were rejected. Merge the remote changes (e.g. 'git pull') before pushing again."
That line is GitHub's. Our rule of thumb at ZeroShot Studio: a rejected push is not a broken remote. It is Git refusing to hide commits you do not have yet.
flowchart TD
Reject["git push rejected: non-fast-forward error"] --> Fetch["git fetch origin"]
Fetch --> Integrate["git pull / git rebase origin/main"]
Integrate --> Resolve["Resolve any line conflicts locally"]
Resolve --> Commit["Commit integrated history"]
Commit --> Push["git push origin succeeds"]What are the required prerequisites?
GitHub's page assumes origin exists and your push just failed. This recipe creates that failure: a private scratch repo, two clones, one unique commit on each side. You need Git 2.39+, authenticated gh 2.40+, and the three prompt locks. Never --web. Never git init in $HOME.
- Git 2.39+ / 2.45+:
clone,fetch,merge --no-edit,pull --no-rebase --no-edit,push,rev-parse,ls-remote - gh 2.40+ / 2.67+: non-interactive
repo create --private - Auth:
gh api userreturns a login. Token already ingh auth - Prompt lock:
GIT_TERMINAL_PROMPT=0,GH_PROMPT_DISABLED=1,GH_PAGER=cat. Fail missing credentials in under 5 seconds - Path:
$HOME/nff-scratch/clone-aandclone-b.--privateonly. Two clones, not worktrees
When we omitted --private, gh waited on Visibility until timeout. Abort if gh api user is empty. Commits here use one-shot git -c flags, not --global. Lasting identity is How to Get Started with Git.
| History shape | Remote vs local | git push origin main | This recipe |
|---|---|---|---|
| Fast-forward | Remote is ancestor of HEAD | Accepted | See the push how-to |
| Behind only | HEAD is ancestor of remote | Rejected, non-fast-forward | See the fetch/pull item |
| Diverged | Neither is an ancestor | Rejected, non-fast-forward | Fetch, merge --no-edit, push |
| Force overwrite | Replace remote history | --force would succeed | Abort. Never --force |
How do you implement the step-by-step recipe?
Run these from $HOME/nff-scratch. Always -m on commit, --private on create, --no-edit on merge. Edit different files on each clone so the merge has no conflicts. Do not rebase, --force, --ff-only, or --web.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --version && gh --version && gh api user --jq .loginExpected: Git 2.39+, a gh version line, and a login. Missing binaries: brew install git gh or sudo apt-get update -y && sudo apt-get install -y git gh. If gh api user fails, stop. Never --web.
-
Create the private GitHub repository, seed
clone-a, and clone it intoclone-b. Both clones must start on the same seed SHA.mkdir -p "$HOME/nff-scratch" && cd "$HOME/nff-scratch"OWNER="$(gh api user --jq .login)"; REPO="nff-scratch"if gh repo view "${OWNER}/${REPO}" >/dev/null 2>&1; then echo "ABORT repo exists"; exit 1; fiif [ -e clone-a ] || [ -e clone-b ]; then echo "ABORT local clones exist"; exit 1; fimkdir clone-a && cd clone-a && git init -b mainprintf '%s\n' '# Non-fast-forward scratch' 'Private drill. Safe to delete.' > README.mdgit add README.mdgit -c user.name='NFF Scratch' -c user.email='scratch@example.invalid' \ commit -m "Seed main on origin"gh repo create "$REPO" --private --source=. --remote=origin --push \ --description "Throwaway non-fast-forward drill. Safe to delete."cd "$HOME/nff-scratch"git clone "$(git -C clone-a remote get-url origin)" clone-btest "$(git -C clone-a rev-parse HEAD)" = "$(git -C clone-b rev-parse HEAD)"echo seed-okExpected: matching seed SHAs, then
seed-ok. -
Diverge both clones: push a unique file from A, commit a unique file on B, and do not fetch on B. GitHub's failure case is another person already pushed to the same branch.
cd "$HOME/nff-scratch/clone-a"printf '%s\n' 'Commit from clone A only.' > from-a.txtgit add from-a.txtgit -c user.name='NFF Scratch' -c user.email='scratch@example.invalid' \ commit -m "Unique commit on clone A"git push origin maincd "$HOME/nff-scratch/clone-b"printf '%s\n' 'Commit from clone B only.' > from-b.txtgit add from-b.txtgit -c user.name='NFF Scratch' -c user.email='scratch@example.invalid' \ commit -m "Unique commit on clone B"A_SHA="$(git -C ../clone-a rev-parse HEAD)"B_SHA="$(git rev-parse HEAD)"REMOTE_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"test "$A_SHA" = "$REMOTE_SHA" && test "$B_SHA" != "$REMOTE_SHA"echo diverge-okExpected: origin equals clone A, clone B's
HEADdiffers, thendiverge-ok. -
Push from clone B and prove Git rejects a non-fast-forward. GitHub's command is
git push origin main. This step must fail. Capture stderr. Do not pass--force.cd "$HOME/nff-scratch/clone-b"set +egit push origin main >push.out 2>push.errrc=$?set -etest "$rc" -ne 0grep -F '[rejected]' push.errgrep -F 'non-fast-forward' push.errecho reject-okExpected: exit code 1,
! [rejected] main -> main (non-fast-forward), thenreject-ok. -
Fetch origin on clone B and prove
HEADdid not move. GitHub's first recovery command isgit fetch origin. Fetch downloads A's commit intoorigin/mainwithout merging.cd "$HOME/nff-scratch/clone-b"BEFORE="$(git rev-parse HEAD)"; git fetch originAFTER="$(git rev-parse HEAD)"; ORIGIN="$(git rev-parse origin/main)"MB="$(git merge-base HEAD origin/main)"test "$BEFORE" = "$AFTER" && test "$AFTER" != "$ORIGIN"test "$ORIGIN" = "$(git -C ../clone-a rev-parse HEAD)"test "$MB" != "$AFTER" && test "$MB" != "$ORIGIN"echo fetch-okExpected:
HEADstill B's commit,origin/mainequals clone A, merge-base is the seed, thenfetch-ok.--ff-onlywould fail from here. -
Merge
origin/maininto clone B with a merge commit, not a rebase. GitHub's snippet writesgit merge origin YOUR_BRANCH_NAME. I found that form looks for a local branch namedorigin. Usegit merge --no-edit origin/main. Do not pass--ff-only. Do not rebase.cd "$HOME/nff-scratch/clone-b"git merge --no-edit origin/maintest "$(git cat-file -p HEAD | grep -c '^parent ')" = "2"test -f from-a.txt && test -f from-b.txtecho merge-okGitHub's combined alternative is
git pull origin YOUR_BRANCH_NAME. Use it instead of steps 4 and 5:git -c pull.rebase=false pull --no-rebase --no-edit origin main. Expected: two parents, both unique files, thenmerge-ok. -
Push the merge commit from clone B and prove origin moved. After the merge, local
HEADis a descendant of the old remote tip, so the second push is a fast-forward on the server.cd "$HOME/nff-scratch/clone-b"git push origin mainHEAD_SHA="$(git rev-parse HEAD)"test "$HEAD_SHA" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"test "$HEAD_SHA" = "$(git rev-parse origin/main)"echo push-okExpected: clone B
HEADequalsls-remoteandorigin/main, thenpush-ok.
How do you verify the deployment works?
Run this probe from any directory. It must finish in under 5 seconds and print VERIFY_OK.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0ROOT="$HOME/nff-scratch"test -d "$ROOT/clone-a/.git" && test -d "$ROOT/clone-b/.git" || { echo VERIFY_FAIL missing_clone; exit 1; }test "$(git -C "$ROOT/clone-b" cat-file -p HEAD | grep -c '^parent ')" = "2" || { echo VERIFY_FAIL not_merge; exit 1; }test -f "$ROOT/clone-b/from-a.txt" && test -f "$ROOT/clone-b/from-b.txt" || { echo VERIFY_FAIL files; exit 1; }B_SHA="$(git -C "$ROOT/clone-b" rev-parse HEAD)"test "${#B_SHA}" = "40" || { echo VERIFY_FAIL sha_length; exit 1; }test "$B_SHA" = "$(git -C "$ROOT/clone-b" rev-parse origin/main)" || { echo VERIFY_FAIL tracking; exit 1; }test "$B_SHA" = "$(git -C "$ROOT/clone-b" ls-remote origin refs/heads/main | awk '{print $1}')" || { echo VERIFY_FAIL ls_remote; exit 1; }test -z "$(git -C "$ROOT/clone-b" status --porcelain=v1)" || { echo VERIFY_FAIL dirty; exit 1; }echo VERIFY_OKecho "$B_SHA"Expected stdout (SHA varies):
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffeWhen we ran this probe at ZeroShot Studio, we got exit code 0 in 1.1 seconds. VERIFY_FAIL not_merge means clone B rebased or fast-forwarded. Optional cleanup:
OWNER="$(gh api user --jq .login)"gh repo delete "${OWNER}/nff-scratch" --yesrm -rf "$HOME/nff-scratch"--yes keeps delete non-interactive.
What are the common production failure modes?
I have seen each of these on a live branch.
--forceafter[rejected]:git push --forcewould drop clone A's commit. Stop. Fetch, merge, push.--ff-onlycopied from the fetch/pull recipe:git merge --ff-only origin/mainexits non-zero when histories diverged. Drop--ff-only. If a merge already started,git merge --abortand rerungit merge --no-edit origin/main.- GitHub's
git merge origin YOUR_BRANCH_NAMEsnippet: That form tries to merge two named refs. Usegit merge --no-edit origin/main. Pull isgit -c pull.rebase=false pull --no-rebase --no-edit origin main. - Same-file merge conflict: If both clones edit
README.md, merge stops. This recipe writesfrom-a.txtandfrom-b.txt. On a real conflict, commit the resolution orgit merge --abort. - Visibility, login, or
pull.rebasesurprise:gh repo createwithout--privateopens a picker. Export the three locks. Pass--private. Never--web. Baregit pullon Git 2.27+ may rebase. Pin--no-rebase. If Git starts a rebase,git rebase --abortand stop.
FAQ
Why did git push print ! [rejected] ... (non-fast-forward)?
Origin has commits that are not ancestors of your local tip. Fetch, merge (or pull), then push. Do not --force.
Is git pull origin main the same as git fetch plus git merge origin/main?
Yes. GitHub's page lists both. git -c pull.rebase=false pull --no-rebase --no-edit origin main runs fetch plus merge. You still git push origin main after either path.
Why not git push --force or a rebase to clear the reject?
--force can drop the other clone's commit. Rebase rewrites local commits and is a later item. A merge commit makes the next push a fast-forward.
What should I do if git merge --ff-only origin/main fails after the fetch?
--ff-only is for a behind-only clone. Run git merge --no-edit origin/main instead. If a merge is already in progress, git merge --abort first.
Do I need GitHub Desktop or a browser to finish this?
No. This recipe is Git plus gh. Never --web. If gh api user fails, authenticate gh in a human session first.