On this page
Somebody opens a pull request from a fork, and you want to run the
branch before you comment on it. You know the two commands: fetch the
fork’s URL, then git merge FETCH_HEAD. They’ve worked since the day
you learned them. Today they merge a branch you didn’t name, tell you
it went fine, and the work you wanted stays outside your history.
Below, both ways this goes wrong: a merge that quietly does nothing, then a retry that quietly merges the wrong thing. The rescue and the one-refspec fix follow.
A branch you can only reach by URL
Your clone (Git 2.51, stock configuration) knows one remote, the
project’s own repository. The contributor’s work lives on their
fork, which you haven’t added as a remote; their branch
retry-backoff is the one you want to try.
$ git status -sb
## main...origin/main
$ git log --oneline
f0995ba Document the retry flag
7a1ba22 Add the sync worker
Clean tree, in step with upstream. Fetch the fork’s branch by URL:
$ git fetch …/fork.git retry-backoff
From …/fork
* branch retry-backoff -> FETCH_HEAD
No remote and no local branch are involved, so the commits land in
your repository with exactly one handle: that FETCH_HEAD at the end
of the line. It is not a branch. It is a file:
$ cat .git/FETCH_HEAD
5e65e4da552a78dee284c01a3152fd73441acddf branch 'retry-backoff' of …/fork
One line — the hash the fetch brought, and where it came from. A receipt for the last fetch, which so far is yours. Git resolves it like any ref:
$ git rev-parse FETCH_HEAD
5e65e4da552a78dee284c01a3152fd73441acddf
Merge right now and you get exactly that commit. Most days that’s what happens, which is how the habit survives.
The merge that merges nothing
Today you don’t merge right now. You spend a minute reading the pull request description. While you read, something fetches: a second terminal, a script, your editor’s Git pane, a desktop client keeping its branch list fresh. To play that part yourself, run the most ordinary command there is:
$ git fetch origin
From …/origin
7a1ba22..a2ebd09 release -> origin/release
Upstream news, and boring news at that: a maintenance branch you don’t care about moved. Now read the receipt again:
$ cat .git/FETCH_HEAD
f0995ba81d9dd9fca2426b0aa35bad551a9cbcca branch 'main' of …/origin
a2ebd09ad4d4a846ded331ab98226d6ab8e281b4 not-for-merge branch 'release' of …/origin
Rewritten top to bottom. The fork line is gone; the newer fetch
recorded its own haul — upstream main, plus the release branch it
marked not-for-merge. FETCH_HEAD describes the last fetch, and
yours stopped being the last fetch while you were reading. Now the
merge you meant to run:
$ git merge FETCH_HEAD
Already up to date.
$ git log --oneline -3
f0995ba Document the retry flag
7a1ba22 Add the sync worker
Git merged the one line not marked not-for-merge: upstream main,
which you already had. True statement, wrong branch. The backoff
commit sits in your repository attached to nothing, and the screen
gives you no reason to go looking for it. The likeliest human reading
of “Already up to date” is that the fork’s work must have landed
some other way.
The retry that merges the wrong thing
Suppose you do go looking, notice the change you expected is missing
from the log, and decide to redo the dance. The world has moved on a
little: a teammate has pushed a logging tweak to upstream main, and
you’ve made a small commit of your own in the meantime:
$ git commit -am "Note the retry default in the README"
[main 072bb1f] Note the retry default in the README
1 file changed, 2 insertions(+)
Fetch the fork again:
$ git fetch …/fork.git retry-backoff
From …/fork
* branch retry-backoff -> FETCH_HEAD
And again something else fetches before you merge, this time with real news:
$ git fetch origin
From …/origin
f0995ba..23af86b main -> origin/main
The receipt, one more time:
$ cat .git/FETCH_HEAD
23af86bc38666439c01cd3d740f98c0d1edf1581 branch 'main' of …/origin
a2ebd09ad4d4a846ded331ab98226d6ab8e281b4 not-for-merge branch 'release' of …/origin
The same swap as before, except this time the candidate line names a commit you don’t have. The merge goes through:
$ git merge FETCH_HEAD
Merge made by the 'ort' strategy.
sync.py | 1 +
1 file changed, 1 insertion(+)
Files changed, a merge commit minted, nothing to complain about. Then you read the log:
$ git log --oneline -4
7381c9f Merge branch 'main' of …/origin
072bb1f Note the retry default in the README
23af86b Log a line when sync gives up
f0995ba Document the retry flag
You asked for retry-backoff and merged upstream main. The subject
line of the merge commit is the one place the swap is named, and
only if you read it. The branch you wanted is exactly as unmerged as
when you started.
Getting back to before
What landed isn’t damage in the usual sense; it’s your own project’s
main. But it’s an integration you didn’t choose, and pushed
upstream it becomes a merge commit you’d have to explain. Wind it
back. One early exit: if your merge stopped on a conflict instead of
completing, git merge --abort is the entire rescue, since nothing
was committed. For the merge that completed, see what Git recorded:
$ git reflog -3
7381c9f HEAD@{0}: merge 23af86bc38666439c01cd3d740f98c0d1edf1581: Merge made by the 'ort' strategy.
072bb1f HEAD@{1}: commit: Note the retry default in the README
f0995ba HEAD@{2}: clone: from …/origin.git
The newest entry is the merge, logged against a raw hash. The line under it is where you stood, and ORIG_HEAD points at the same place (Git set it just before the merge moved you). With a clean tree, straight after the merge, returning is one command:
$ git reset --hard ORIG_HEAD
HEAD is now at 072bb1f Note the retry default in the README
$ git log --oneline -2
072bb1f Note the retry default in the README
f0995ba Document the retry flag
Back where you stood, with the wrong merge gone. A fast-forward sets ORIG_HEAD too, so the same command covers the variant where the bad merge arrived as one.
When the tree is dirty, order matters: saving a stash performs a
hard reset of its own, and that repoints ORIG_HEAD at the bad merge,
turning the reset above into a no-op. So copy the hash from the
reflog line under the merge first, then git stash --include-untracked,
then git reset --hard to the copied hash, then git stash pop
once you’re back. If the pop lands in a conflict (your edits overlap
a file the merge changed), the stash entry is kept: resolve the
markers, then git stash drop. The copied-hash form is also the
fallback whenever ORIG_HEAD has been repointed by anything that ran
in between (a rebase, a reset, that stash); the reflog keeps
answering after ORIG_HEAD has moved on.
One more exit: if you’d already built commits on top of the bad
merge, a hard reset takes those off the branch as well. Write down
their hashes before resetting and git cherry-pick them back,
oldest first — or skip the reset entirely and merge the branch you
actually meant on top; the refspec fix below gives it a name.
One file, last writer wins
All of it is one mechanism. .git/FETCH_HEAD is one file per
checkout (a linked worktree keeps its own copy), and every plain
fetch in that checkout rewrites the whole thing; you’ve already
met the cast. Each line is one ref the fetch brought, and the
second column marks it either as a merge candidate (the empty
slot you saw) or not-for-merge. Name a branch in the fetch
command and that branch is the candidate. Run a bare git fetch origin
and the candidate is whatever your current branch’s configured
upstream is. git merge FETCH_HEAD merges the candidate lines
and nothing else; on a branch with no upstream, where a bare
fetch marks every line not-for-merge, it merges nothing at
all and still prints “Already up to date”.
So the old two-command pattern decodes as: merge whatever this checkout’s most recent fetch, run by anyone, was told to bring. When those instructions were first written, you were usually the only fetcher on the machine. What changed isn’t the file; it’s how much software now fetches on your behalf.
Give the fetch a destination
The fix is to stop treating the note as a name. Tell the fetch where to put what it brings, with exactly the same colon refspec that updates a branch you’re not on:
$ git fetch …/fork.git retry-backoff:pr-retry-backoff
From …/fork
* [new branch] retry-backoff -> pr-retry-backoff
Their retry-backoff, written to a local branch of yours named
pr-retry-backoff. It’s a real ref in your repository now; no
background fetch will move it. Look it over on your own schedule:
$ git log --oneline main..pr-retry-backoff
5e65e4d Add exponential backoff between retries
and merge the name:
$ git merge pr-retry-backoff
Merge made by the 'ort' strategy.
sync.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --oneline -3
b047cfa Merge branch 'pr-retry-backoff'
072bb1f Note the retry default in the README
5e65e4d Add exponential backoff between retries
The backoff commit is in your history at last, under a subject line
that names what you merged. Delete pr-retry-backoff with
git branch -d when the review is done; if you decided against
merging, -d will refuse, and that refusal is Git guarding unmerged
commits (deleting anyway takes -D). If you’d rather not make a
branch at all, the narrower fix is to read the receipt before
anything else rewrites it: right after the fetch, run the command
cat "$(git rev-parse --git-path FETCH_HEAD)" (in a plain clone
that’s just .git/FETCH_HEAD), confirm the line shows their
branch, then merge the hash on it. That narrows the race without
closing it, and a merge by bare hash records a subject with no
branch name in it; the refspec keeps the name. Both fixes are one
idea: merge against something the next fetch can’t rewrite.
$ git status -sb
## main...origin/main [ahead 3, behind 1]
The 1 you’re behind is the teammate’s logging commit; take it in with an ordinary pull whenever you choose, on purpose this time.
Or don’t do any of this
The villain here is a feature. Background fetching is why a client’s branch list is current and why divergence shows up before push time instead of after. Turning it off to protect a two-command habit would be backwards. The race lives in the habit: two commands, one shared file, and no claim on the gap between them.
GitDesktop fetches in the background as a matter of course, so it treats an operation in flight as something a fetch must not be able to steer. When you pull, the tips produced by one fetch are read once and pinned; the fast-forward check, the merge or rebase, and the dropped-commit guard from the previous post all run against those pinned hashes. Its own background fetch can rewrite FETCH_HEAD and advance every tracking ref mid-operation without changing the outcome, because the operation stopped consulting them the moment it decided what to do.
Git merges exactly what you point at. Point at something you own.
Written while building GitDesktop.
A desktop Git client for GitHub, GitLab and Bitbucket — free and open source, with AI you control or hide entirely.