DEV Community

Cover image for What FETCH_HEAD is, and why you shouldn't merge it
Evan
Evan

Posted on Originally published at gitdesktop.app

What FETCH_HEAD is, and why you shouldn't merge it

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(+)
Enter fullscreen mode Exit fullscreen mode

Fetch the fork again:

$ git fetch …/fork.git retry-backoff
From …/fork
 * branch            retry-backoff -> FETCH_HEAD
Enter fullscreen mode Exit fullscreen mode

And again something else fetches before you merge, this time with real news:

$ git fetch origin
From …/origin
   f0995ba..23af86b  main       -> origin/main
Enter fullscreen mode Exit fullscreen mode

The receipt, one more time:

$ cat .git/FETCH_HEAD
23af86bc38666439c01cd3d740f98c0d1edf1581        branch 'main' of …/origin
a2ebd09ad4d4a846ded331ab98226d6ab8e281b4    not-for-merge   branch 'release' of …/origin
Enter fullscreen mode Exit fullscreen mode

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(+)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)