How to fetch and pull from GitHub
Clone twice, push from clone A, then git fetch plus merge or git pull on clone B until HEAD matches origin/main. No rebase, no --web.
What are we building and why?
We are getting origin commits into a second local clone and proving the SHAs match. GitHub's getting-changes page is clone, fetch, merge, and pull. This recipe uses a private scratch repo, two clones, a push from clone A, then git fetch plus merge or git pull on clone B. You do not rebase.
GitHub's Getting changes from a remote repository page is the map. Sending commits is How to Push Commits to GitHub. You do not rebase.
Related reading: How to Understand Git on GitHub and How to Get Started with Git. Authority: git-fetch, git-pull, Working with Remotes.
When we ran the two-clone loop at ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1, fetch left HEAD stale, moved origin/main by 1 commit, and finished in 64 milliseconds. Merge --ff-only matched in 81 milliseconds. Pull --no-rebase --ff-only matched in 80 milliseconds. HTTPS is slower and still under 5 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 working copies until you delete them.
I found agents treat git fetch as a failed pull because README.md did not change. The receipt is git rev-parse, not a changed file list.
"git pull is a convenient shortcut for completing both git fetch and git merge in the same command."
That line is GitHub's. Our rule of thumb at ZeroShot Studio: origin/main is not in this worktree until git rev-parse HEAD equals git rev-parse origin/main.
flowchart LR
Remote["Remote Branch (origin/main)"] --> Fetch["git fetch origin"]
Fetch --> Merge["git merge origin/main (or git pull)"]
Merge --> Worktree["Local Working Tree Synchronized"]What are the required prerequisites?
GitHub's page assumes a remote URL and a local repo that already tracks it. This recipe creates a private scratch repo, then two independent clones. You need Git 2.39+, authenticated gh 2.40+, and the three prompt locks. Never --web. Never git init in $HOME. Two Git worktrees share one object database, so fetch in one would leak into the other. Use two git clone directories.
- Git 2.39+ / 2.45+:
clone,fetch,merge --ff-only,pull --no-rebase,rev-parse - 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/fetch-pull-scratch/clone-aandclone-b.--privateonly. Two clones, not worktrees
When we omitted --private, gh waited on Visibility until timeout. I found agents copy git pull 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.
| Command | Downloads from origin | Updates origin/main | Moves local HEAD | Changes worktree files |
|---|---|---|---|---|
git clone URL dir | Yes, full history | Creates tracking refs | Yes, checks out default | Yes, new directory |
git fetch origin | Yes | Yes | No | No |
git merge origin/main | No | No | Yes, on success | Yes, if files change |
git pull origin main | Yes (fetch) | Yes (fetch) | Yes (merge) | Yes (merge) |
How do you implement the step-by-step recipe?
Run these from $HOME/fetch-pull-scratch. Always -m on commit, --private on create, --ff-only --no-edit on merge, --no-rebase --ff-only on pull. Do not rebase, --force, 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 install -y git gh. If gh api user fails, stop. Never --web.
-
Create the private GitHub repository and seed
clone-awith one commit onmain. Clone B cannot clone an empty default branch. This seed push is setup.mkdir -p "$HOME/fetch-pull-scratch" && cd "$HOME/fetch-pull-scratch"OWNER="$(gh api user --jq .login)"; REPO="fetch-pull-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' '# Fetch pull scratch' 'Private drill. Safe to delete.' > README.mdgit add README.mdgit -c user.name='Fetch Pull Scratch' -c user.email='scratch@example.invalid' \ commit -m "Seed main on origin"gh repo create "$REPO" --private --source=. --remote=origin --push \ --description "Throwaway fetch and pull drill. Safe to delete."test "$(git remote)" = "origin" && test "$(git rev-list --count HEAD)" = "1"test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"echo seed-okExpected:
originathttps://github.com/YOUR_LOGIN/fetch-pull-scratch.git(or SSH), count1,seed-ok. -
Clone origin into
clone-b. GitHub's first-time analogue isgit clone. Use a secondgit clone, notgit worktree add.cd "$HOME/fetch-pull-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)"test "$(git -C clone-b rev-parse HEAD)" = "$(git -C clone-b rev-parse origin/main)"echo clone-okExpected: three identical SHAs, then
clone-ok. -
Push a new commit from
clone-aso origin is ahead ofclone-b. Clone B still has the seed SHA. Do not fetch on B yet.cd "$HOME/fetch-pull-scratch/clone-a"printf '%s\n' 'Second snapshot from clone A.' >> README.mdgit add README.mdgit -c user.name='Fetch Pull Scratch' -c user.email='scratch@example.invalid' \ commit -m "Push from clone A"git push origin mainA_SHA="$(git rev-parse HEAD)"; B_HEAD="$(git -C ../clone-b rev-parse HEAD)"test "$A_SHA" != "$B_HEAD" && test "$(git rev-list --count HEAD)" = "2"test "$(git -C ../clone-b rev-list --count HEAD)" = "1"echo ahead-okExpected:
main -> main, counts2on A and1on B, thenahead-ok. -
Fetch on
clone-band proveHEADdid not move. GitHub's command isgit fetch REMOTE-NAME. Fetch grabs new remote-tracking branches without merging.cd "$HOME/fetch-pull-scratch/clone-b"BEFORE="$(git rev-parse HEAD)"; git fetch originAFTER="$(git rev-parse HEAD)"; ORIGIN="$(git rev-parse origin/main)"git log --oneline HEAD..origin/maintest "$BEFORE" = "$AFTER" && test "$AFTER" != "$ORIGIN"test "$ORIGIN" = "$(git -C ../clone-a rev-parse HEAD)"echo fetch-okExpected:
main -> origin/main, one onelinePush from clone A, thenfetch-ok. -
Merge
origin/mainintoclone-band prove the SHAs match. GitHub's command isgit merge REMOTE-NAME/BRANCH-NAME.--ff-onlykeeps this on a fast-forward.--no-editskips an editor. If Git refuses a non-fast-forward, stop.cd "$HOME/fetch-pull-scratch/clone-b"git merge --ff-only --no-edit origin/mainB_HEAD="$(git rev-parse HEAD)"test "$B_HEAD" = "$(git rev-parse origin/main)"test "$B_HEAD" = "$(git -C ../clone-a rev-parse HEAD)"test "$(git log -1 --pretty=%s)" = "Push from clone A"echo merge-okExpected: Fast-forward, matching SHAs, then
merge-ok. If a merge started,git merge --abortand stop. -
Push a third commit from
clone-a, then pull onclone-b. GitHub's command isgit pull REMOTE-NAME BRANCH-NAME. Local work must already be committed.--no-rebasepins merge.--ff-onlyrefuses a diverged history.cd "$HOME/fetch-pull-scratch/clone-a"printf '%s\n' 'Third snapshot from clone A.' >> README.mdgit add README.mdgit -c user.name='Fetch Pull Scratch' -c user.email='scratch@example.invalid' \ commit -m "Second push from clone A"git push origin maincd "$HOME/fetch-pull-scratch/clone-b"test -z "$(git status --porcelain=v1)"git -c pull.rebase=false pull --no-rebase --ff-only origin mainB_HEAD="$(git rev-parse HEAD)"test "$B_HEAD" = "$(git -C ../clone-a rev-parse HEAD)"test "$B_HEAD" = "$(git rev-parse origin/main)"test "$(git log -1 --pretty=%s)" = "Second push from clone A"echo pull-okExpected: clone B fast-forwards, matching SHAs, subject
Second push from clone A, thenpull-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/fetch-pull-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-a" rev-list --count HEAD)" = "3" || { echo VERIFY_FAIL count_a; exit 1; }test "$(git -C "$ROOT/clone-b" rev-list --count HEAD)" = "3" || { echo VERIFY_FAIL count_b; exit 1; }A_SHA="$(git -C "$ROOT/clone-a" rev-parse HEAD)"test "${#A_SHA}" = "40" || { echo VERIFY_FAIL sha_length; exit 1; }test "$A_SHA" = "$(git -C "$ROOT/clone-b" rev-parse HEAD)" || { echo VERIFY_FAIL clone_shas; exit 1; }test "$A_SHA" = "$(git -C "$ROOT/clone-b" rev-parse origin/main)" || { echo VERIFY_FAIL tracking; exit 1; }test "$A_SHA" = "$(git -C "$ROOT/clone-a" ls-remote origin refs/heads/main | awk '{print $1}')" || { echo VERIFY_FAIL ls_remote; exit 1; }test -z "$(git -C "$ROOT/clone-a" status --porcelain=v1)" && test -z "$(git -C "$ROOT/clone-b" status --porcelain=v1)" || { echo VERIFY_FAIL dirty; exit 1; }echo VERIFY_OKecho "$A_SHA"Expected stdout (SHA varies):
VERIFY_OKc868a0f06e4f954ea1fa14082c3e75dd2307174bWhen we ran this probe at ZeroShot Studio, exit code 0 in 1.1 seconds. VERIFY_FAIL tracking means clone B fetched but did not merge. Optional cleanup:
OWNER="$(gh api user --jq .login)"gh repo delete "${OWNER}/fetch-pull-scratch" --yesrm -rf "$HOME/fetch-pull-scratch"--yes keeps delete non-interactive.
What are the common production failure modes?
Abort. Do not rebase, --force, or walk a non-fast-forward recovery.
- Fetch looks like a no-op:
git fetchdoes not changeHEADor the worktree. Comparegit rev-parse HEADtogit rev-parse origin/main. If they differ, fetch worked. Merge or pull next. - Pull on a dirty worktree: If
git status --porcelain=v1is not empty, stop. Commit with-mor discard in a throwaway clone. - Visibility or login prompt:
gh repo createwithout--privateopens a picker. Fetch without credentials waits onUsername for 'https://github.com'. Export the three locks. Pass--private. Never--web. - Git 2.27+
pull.rebasewarning or an unexpected rebase: Baregit pullmay warn or rebase. Pin merge withgit -c pull.rebase=false pull --no-rebase --ff-only origin main. If Git starts a rebase, abort. Rebase is a later item. - Non-fast-forward or a merge already in progress:
--ff-onlyexits non-zero when histories diverged. Rungit merge --abort, then stop. Do not resolve conflicts. Do notgit pull --rebase.
In our production testing, this fetch-then-merge split stopped agents from reporting a successful fetch as a failed pull. The probe is the receipt.
FAQ
What is the difference between git fetch plus git merge and git pull?
git fetch origin updates origin/main only. git merge origin/main then moves HEAD. git pull origin main runs both. This recipe runs them split once, then combined, and proves both with git rev-parse.
Does git fetch change files in my working tree?
No. Fetch updates remote-tracking refs under refs/remotes/origin/. Your branch, index, and worktree stay put.
What should I do if git pull or git merge stops with a conflict?
Stop. GitHub's abort is git merge --abort. Do not resolve conflicts, rebase, or --force from this scratch recipe.
Why two independent clones instead of two worktrees?
Worktrees share one .git object database. Fetch in worktree A would update origin/main for worktree B without B running git fetch.
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.