Back to Resources

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
6 linescompact
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"]
Rendered from Mermaid source with the native ZeroLabs diagram container.

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 user returns a login. Token already in gh 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-a and clone-b. --private only. 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 shapeRemote vs localgit push origin mainThis recipe
Fast-forwardRemote is ancestor of HEADAcceptedSee the push how-to
Behind onlyHEAD is ancestor of remoteRejected, non-fast-forwardSee the fetch/pull item
DivergedNeither is an ancestorRejected, non-fast-forwardFetch, merge --no-edit, push
Force overwriteReplace remote history--force would succeedAbort. 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.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --version && gh --version && gh api user --jq .login

Expected: 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.

  1. Create the private GitHub repository, seed clone-a, and clone it into clone-b. Both clones must start on the same seed SHA.

    Terminalbash
    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-ok

    Expected: matching seed SHAs, then seed-ok.

  2. 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.

    Terminalbash
    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-ok

    Expected: origin equals clone A, clone B's HEAD differs, then diverge-ok.

  3. 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.

    Terminalbash
    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-ok

    Expected: exit code 1, ! [rejected] main -> main (non-fast-forward), then reject-ok.

  4. Fetch origin on clone B and prove HEAD did not move. GitHub's first recovery command is git fetch origin. Fetch downloads A's commit into origin/main without merging.

    Terminalbash
    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-ok

    Expected: HEAD still B's commit, origin/main equals clone A, merge-base is the seed, then fetch-ok. --ff-only would fail from here.

  5. Merge origin/main into clone B with a merge commit, not a rebase. GitHub's snippet writes git merge origin YOUR_BRANCH_NAME. I found that form looks for a local branch named origin. Use git merge --no-edit origin/main. Do not pass --ff-only. Do not rebase.

    Terminalbash
    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-ok

    GitHub'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, then merge-ok.

  6. Push the merge commit from clone B and prove origin moved. After the merge, local HEAD is a descendant of the old remote tip, so the second push is a fast-forward on the server.

    Terminalbash
    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-ok

    Expected: clone B HEAD equals ls-remote and origin/main, then push-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.

Terminalbash
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):

text
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe

When 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:

Terminalbash
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.

  • --force after [rejected]: git push --force would drop clone A's commit. Stop. Fetch, merge, push.
  • --ff-only copied from the fetch/pull recipe: git merge --ff-only origin/main exits non-zero when histories diverged. Drop --ff-only. If a merge already started, git merge --abort and rerun git merge --no-edit origin/main.
  • GitHub's git merge origin YOUR_BRANCH_NAME snippet: That form tries to merge two named refs. Use git merge --no-edit origin/main. Pull is git -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 writes from-a.txt and from-b.txt. On a real conflict, commit the resolution or git merge --abort.
  • Visibility, login, or pull.rebase surprise: gh repo create without --private opens a picker. Export the three locks. Pass --private. Never --web. Bare git pull on Git 2.27+ may rebase. Pin --no-rebase. If Git starts a rebase, git rebase --abort and 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.

Share