How to fetch and pull from GitHub
Safely update your local code from GitHub with git fetch and git pull. Avoid unintended merge commits and reconcile remote changes without losing AI edits.
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
Why does Git ask how to reconcile divergent branches when running git pull?
Run git config --global pull.rebase true to automatically replay your local commits on top of remote changes without creating messy merge commits.
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.