Back to Resources

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.

Architecture Flow
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 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/fetch-pull-scratch/clone-a and clone-b. --private only. 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.

CommandDownloads from originUpdates origin/mainMoves local HEADChanges worktree files
git clone URL dirYes, full historyCreates tracking refsYes, checks out defaultYes, new directory
git fetch originYesYesNoNo
git merge origin/mainNoNoYes, on successYes, if files change
git pull origin mainYes (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.

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 install -y git gh. If gh api user fails, stop. Never --web.

  1. Create the private GitHub repository and seed clone-a with one commit on main. Clone B cannot clone an empty default branch. This seed push is setup.

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

    Expected: origin at https://github.com/YOUR_LOGIN/fetch-pull-scratch.git (or SSH), count 1, seed-ok.

  2. Clone origin into clone-b. GitHub's first-time analogue is git clone. Use a second git clone, not git worktree add.

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

    Expected: three identical SHAs, then clone-ok.

  3. Push a new commit from clone-a so origin is ahead of clone-b. Clone B still has the seed SHA. Do not fetch on B yet.

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

    Expected: main -> main, counts 2 on A and 1 on B, then ahead-ok.

  4. Fetch on clone-b and prove HEAD did not move. GitHub's command is git fetch REMOTE-NAME. Fetch grabs new remote-tracking branches without merging.

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

    Expected: main -> origin/main, one oneline Push from clone A, then fetch-ok.

  5. Merge origin/main into clone-b and prove the SHAs match. GitHub's command is git merge REMOTE-NAME/BRANCH-NAME. --ff-only keeps this on a fast-forward. --no-edit skips an editor. If Git refuses a non-fast-forward, stop.

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

    Expected: Fast-forward, matching SHAs, then merge-ok. If a merge started, git merge --abort and stop.

  6. Push a third commit from clone-a, then pull on clone-b. GitHub's command is git pull REMOTE-NAME BRANCH-NAME. Local work must already be committed. --no-rebase pins merge. --ff-only refuses a diverged history.

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

    Expected: clone B fast-forwards, matching SHAs, subject Second push from clone A, then pull-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/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):

text
VERIFY_OKc868a0f06e4f954ea1fa14082c3e75dd2307174b

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

Terminalbash
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 fetch does not change HEAD or the worktree. Compare git rev-parse HEAD to git rev-parse origin/main. If they differ, fetch worked. Merge or pull next.
  • Pull on a dirty worktree: If git status --porcelain=v1 is not empty, stop. Commit with -m or discard in a throwaway clone.
  • Visibility or login prompt: gh repo create without --private opens a picker. Fetch without credentials waits on Username for 'https://github.com'. Export the three locks. Pass --private. Never --web.
  • Git 2.27+ pull.rebase warning or an unexpected rebase: Bare git pull may warn or rebase. Pin merge with git -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-only exits non-zero when histories diverged. Run git merge --abort, then stop. Do not resolve conflicts. Do not git 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.

Share