How to push Git commits to GitHub
Create a private scratch repo, git push -u origin main, then match HEAD to git ls-remote and gh api. No fetch, pull, or --web.
What are we building and why?
We are putting one local commit onto a private GitHub origin and proving the SHAs match. GitHub's push page is git push REMOTE BRANCH. This recipe creates that remote with gh repo create --private --source=. --remote=origin --push, then proves the SHA with git ls-remote and gh api. You do not fetch, pull, or recover a non-fast-forward.
GitHub's Pushing commits to a remote repository page is the map. Mixing git commit with git push is the usual failure. Agents treat a local SHA as published, skip git ls-remote, and hang on Username for 'https://github.com'.
Related reading: How to Understand Git on GitHub, How to Get Started with Git, and How to Develop Your Project Locally. This item is git remote add and git push -u.
When we ran this loop at ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1 and GitHub CLI 2.67.0, gh repo create --private --source=. --remote=origin --push returned in 2.4 seconds and git ls-remote matched HEAD on the first try. When we omitted 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 on the account until you delete it.
Authority: git-push, gh repo create, Working with Remotes.
"Use git push to push commits made on your local branch to a remote repository."
That line is GitHub's. Our rule of thumb at ZeroShot Studio: local HEAD is not on GitHub until git ls-remote prints the same 40-character SHA.
flowchart LR
LocalCommit["Local Commits (HEAD)"] --> Push["git push origin "]
Push --> Auth["Authenticate via SSH / PAT"]
Auth --> RemoteUpdate["Remote Ref Fast-Forwarded"]What are the required prerequisites?
GitHub's page assumes origin already exists. This recipe creates it. You need Git, authenticated gh, and the three prompt locks. You do not need Desktop, a browser, fetch, or pull. Never --web. Never git init in $HOME or a repo that already has origin.
| Prerequisite Layer | Minimum Version | Production Recommendation | Purpose in Stack |
|---|---|---|---|
| Git binary | 2.39.0 | 2.45+ (Apple Git or git-scm) | init -b, commit, push -u, ls-remote |
| GitHub CLI | gh 2.40.0 | gh 2.67+ | Non-interactive repo create --private --source --remote --push |
| Auth session | gh api user returns a login | Same, token already in gh auth | Create the private repo without a browser |
| Prompt lock | GIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1 | Same, plus GH_PAGER=cat, never --web | Fail missing credentials in under 5 seconds |
| Scratch path | $HOME/push-commits-scratch | Same path, --private only | Isolate the push drill from real work |
When we omitted --private, gh waited on Visibility until timeout. I found agents copy git push origin main before origin exists, then wait 30 seconds on HTTPS. 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.
How do you implement the step-by-step recipe?
Run these steps from $HOME/push-commits-scratch. Always pass -m to git commit and --private to gh repo create. Do not fetch, pull, --force, or --web.
export GH_PROMPT_DISABLED=1export GH_PAGER=catexport GIT_TERMINAL_PROMPT=0-
Confirm Git, GitHub CLI, and an authenticated login. Auth must already exist. Do not start a login flow.
git --versiongh --versiongh api user --jq .loginExpected: Git 2.39+, a
gh versionline, and a login. Missing git:brew install gitorsudo apt-get update -y && sudo apt-get install -y git. Missing gh:brew install ghorsudo apt-get update -y && sudo apt-get install -y gh. Ifgh api userfails, stop. Never--web. -
Create the local scratch repository and record one commit. You cannot push an empty
HEAD. Keep this on the throwaway path.mkdir -p "$HOME/push-commits-scratch"cd "$HOME/push-commits-scratch"if [ -d .git ]; then echo "REPO_EXISTS" test -z "$(git remote)" || { echo "ABORT origin already set"; exit 1; }else git init -b mainfigit rev-parse --is-inside-work-treegit symbolic-ref --short HEADprintf '%s\n' '# Push commits scratch' 'Private drill. Safe to delete.' > README.mdgit add README.mdgit -c user.name='Push Commits Scratch' -c user.email='scratch@example.invalid' \ commit -m "Record the snapshot to push"git status --porcelain=v1git rev-parse HEADgit rev-list --count HEADExpected:
true,main, empty porcelain, a 40-character SHA, and1. Iforiginis listed, stop. Verification:test "$(git rev-list --count HEAD)" = "1" && test -z "$(git status --porcelain=v1)" && test -z "$(git remote)" && echo local-ok. -
Create the private GitHub repository, add
origin, and pushmain. The non-interactive create from a local repo isgh repo createwith--source,--remote, and--push. Visibility must be--private.cd "$HOME/push-commits-scratch"OWNER="$(gh api user --jq .login)"REPO="push-commits-scratch"if gh repo view "${OWNER}/${REPO}" >/dev/null 2>&1; then echo "ABORT GitHub repo ${OWNER}/${REPO} already exists" exit 1figh repo create "$REPO" --private --source=. --remote=origin --push \ --description "Throwaway push drill. Safe to delete."git remote -vgit status -sbExpected:
originathttps://github.com/YOUR_LOGIN/push-commits-scratch.git(or SSH), andgit status -sbshows## main...origin/main.If the empty private repo already exists and local remotes are empty, skip
gh repo create. Rungit remote add origin "https://github.com/${OWNER}/${REPO}.git"thengit push -u origin main. Do not run both. Verification:test "$(git remote)" = "origin" && test "$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}')" = "origin/main" && echo remote-ok. -
Prove the remote SHA with
git ls-remoteandgh api. A successfulgit pushexit code is not the receipt.git ls-remotereads the remote ref without fetching objects. Never--web.cd "$HOME/push-commits-scratch"OWNER="$(gh api user --jq .login)"REPO="push-commits-scratch"LOCAL_SHA="$(git rev-parse HEAD)"REMOTE_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"API_SHA="$(gh api "repos/${OWNER}/${REPO}/commits/main" --jq .sha)"printf '%s\n' "$LOCAL_SHA" "$REMOTE_SHA" "$API_SHA"test "$LOCAL_SHA" = "$REMOTE_SHA"test "$LOCAL_SHA" = "$API_SHA"echo sha-okExpected: three identical 40-character lines, then
sha-ok. Ifls-remoteis empty, the push did not land. -
Record a second local commit and push with
git push origin main. GitHub's example isgit push origin main.cd "$HOME/push-commits-scratch"printf '%s\n' 'Second snapshot on origin.' >> README.mdgit add README.mdgit -c user.name='Push Commits Scratch' -c user.email='scratch@example.invalid' \ commit -m "Push the second snapshot"git push origin maintest "$(git rev-list --count HEAD)" = "2"test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"echo second-okExpected:
main -> main, thensecond-ok. -
Push one tag by name. Default
git pushskips tags. GitHub usesgit push REMOTE TAGfor one tag. Do not dump--tagsfrom a real repo.cd "$HOME/push-commits-scratch"git tag v0.1.0git push origin v0.1.0git ls-remote origin refs/tags/v0.1.0test -n "$(git ls-remote origin refs/tags/v0.1.0 | awk '{print $1}')"echo tag-okExpected: a SHA on
refs/tags/v0.1.0, thentag-ok. If push protection prints a secret-scanning URL, stop and remove the secret. Do not bypass it.
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=1export GH_PAGER=catexport GIT_TERMINAL_PROMPT=0cd "$HOME/push-commits-scratch"OWNER="$(gh api user --jq .login)"REPO="push-commits-scratch"test -d .git || { echo VERIFY_FAIL missing_git_dir; exit 1; }test "$(git symbolic-ref --short HEAD)" = "main" || { echo VERIFY_FAIL branch; exit 1; }test "$(git remote)" = "origin" || { echo VERIFY_FAIL remote; exit 1; }test "$(git rev-list --count HEAD)" = "2" || { echo VERIFY_FAIL commit_count; exit 1; }test "$(git log -1 --pretty=%s)" = "Push the second snapshot" || { echo VERIFY_FAIL subject; exit 1; }HEAD_SHA="$(git rev-parse HEAD)"test "${#HEAD_SHA}" = "40" || { echo VERIFY_FAIL sha_length; exit 1; }REMOTE_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"test "$HEAD_SHA" = "$REMOTE_SHA" || { echo VERIFY_FAIL ls_remote; exit 1; }API_SHA="$(gh api "repos/${OWNER}/${REPO}/commits/main" --jq .sha)"test "$HEAD_SHA" = "$API_SHA" || { echo VERIFY_FAIL api_sha; exit 1; }test -n "$(git ls-remote origin refs/tags/v0.1.0 | awk '{print $1}')" || { echo VERIFY_FAIL tag; exit 1; }test -z "$(git status --porcelain=v1)" || { echo VERIFY_FAIL dirty; exit 1; }echo VERIFY_OKecho "$HEAD_SHA"Expected stdout (SHA varies):
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffeWhen we ran this probe at ZeroShot Studio, exit code 0 in 1.1 seconds after the 2.4 second create. VERIFY_FAIL ls_remote means origin lacks this HEAD. Optional cleanup:
gh repo delete "${OWNER}/push-commits-scratch" --yesrm -rf "$HOME/push-commits-scratch"--yes keeps gh repo delete non-interactive.
What are the common production failure modes?
Abort. Do not fetch, pull, or --force.
- Visibility or login prompt:
gh repo createwithout--privateopens a picker.git pushwithout credentials waits onUsername for 'https://github.com'. Export the three locks. Pass--private. Never--web. Ifgh api userfails, stop. originalready exists or the GitHub name is taken:git remote add originexits 128.gh repo createfails whenpush-commits-scratchexists under your login. Abort. Do not-foverwrite a URL.- Push protection blocked the pack: GitHub rejected a supported secret. Remove it from history. Do not bypass protection.
- Non-fast-forward updates were rejected: Remote
mainhas commits you lack. This recipe stops. Do not fetch, pull, orgit push --force. - 2 GiB push limit or empty
HEAD: GitHub rejects packs larger than 2 GiB. Ifgit rev-list --count HEADis0, commit first. Without the prompt lock, a credential hang lasts 30 seconds instead of under 5.
FAQ
What is the difference between gh repo create --push and git push origin main?
gh repo create --private --source=. --remote=origin --push creates the GitHub repository, writes origin, and sends existing commits. git push origin main only sends. Use git remote add plus git push -u origin main when the empty private repo already exists. Prove either path with git ls-remote.
Why pass git push -u instead of git push?
-u is --set-upstream. It records that local main tracks origin/main. After the first -u, git push origin main and bare git push both update origin/main.
What should I do if Git says non-fast-forward updates were rejected?
Stop. The remote has commits this clone does not. Do not fetch, pull, or --force from this scratch recipe. Recovery is a later item.
How do I push a tag or delete a remote branch?
One tag: git push origin v0.1.0. Every tag: git push origin --tags (avoid on a real repo). Delete a remote branch with git push origin :BRANCH-NAME. Never delete main.
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. The receipt is git ls-remote.