Back to Resources

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

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

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