DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Git 2.56's fetch.followRemoteHEAD setting fixes stale origin/HEAD for every remote

I have an origin/HEAD pointing at origin/master in a repository I cloned two years ago, long after the project moved its default branch to main. It has never fixed itself. Every git fetch since then has pulled the right commits and left that one symbolic ref exactly where it was.

Git 2.56, released in the past few weeks, adds a config variable called fetch.followRemoteHEAD that is supposed to address this, by giving you one global switch instead of a separate setting per remote. I built three versions of Git from source — the Ubuntu-packaged 2.43.0, a 2.55.0 I compiled myself, and 2.56.0 — and used them against a handful of local bare repositories to see what the setting actually does, what it doesn't, and what happens when you reach for the wrong value.

Reproducing the stale ref

First I confirmed the bug I already own a copy of. I cloned a bare repo while its HEAD pointed at main, then renamed the remote's default branch to trunk, the way GitHub does when you change the default branch in the settings page:

$ git clone remote2 clone-create -q
$ git -C clone-create symbolic-ref refs/remotes/origin/HEAD
refs/remotes/origin/main
$ git --git-dir=remote2 branch -m main trunk
$ git -C clone-create fetch
From /tmp/gittest/remote2
 * [new branch]      trunk      -> origin/trunk
$ git -C clone-create symbolic-ref refs/remotes/origin/HEAD
refs/remotes/origin/main
Enter fullscreen mode Exit fullscreen mode

The fetch worked. The new branch arrived. origin/HEAD still points at origin/main, a ref to a branch that no longer exists on the remote. That's fetch.followRemoteHEAD's default value, create: it will only ever create the symref if none exists. Once it exists, nothing updates it, forever, on every Git version that has the setting at all.

Setting the per-remote value to warn doesn't fix it either, it just tells you:

hint: Run 'git remote set-head origin trunk' to follow the change, or modify
hint: either of the 'remote.origin.followRemoteHEAD' or 'fetch.followRemoteHEAD'
hint: configuration variables to handle the situation differently.
hint:
hint: Using this specific setting
hint:
hint:     git config set remote.origin.followRemoteHEAD warn-if-not-trunk
hint:
hint: will suppress the warning until the remote changes HEAD to something else.
'HEAD' at 'origin' is 'trunk', but we have 'main' locally.
Enter fullscreen mode Exit fullscreen mode

always, set per-remote, does what you'd expect: the next fetch silently repoints the symref to trunk. All three of these modes (create, warn, always, plus never) existed before 2.56. What's new in 2.56 is that you can set the default for all of them at once, with fetch.followRemoteHEAD, instead of writing remote.<name>.followRemoteHEAD into every remote's config by hand.

Does the global setting actually save you per-remote config

This is the part worth measuring rather than describing. I set fetch.followRemoteHEAD always once, in a config file pointed to by GIT_CONFIG_GLOBAL so I wasn't touching anything outside my test directory, then added three remotes to a fresh repository and fetched all of them:

$ git config --global fetch.followRemoteHEAD always
$ git remote add r1 ../remote1   # HEAD -> trunk
$ git remote add r2 ../remote2   # HEAD -> trunk
$ git remote add r3 ../remote3   # HEAD -> main
$ git fetch --all
...
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/trunk
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/trunk
$ git symbolic-ref refs/remotes/r3/HEAD
refs/remotes/r3/main
$ grep -c followRemoteHEAD .git/config
0
Enter fullscreen mode Exit fullscreen mode

Zero lines of per-remote config across three remotes, all three correctly tracking. But that much is also true of the old default behaviour, create, for a brand new remote with no existing symref — I'll come back to that, because it's where I fooled myself for a while.

The real test is what happens later, when a remote I already have changes its default branch again, with no new config written anywhere:

$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/trunk
$ git --git-dir=../remote1 branch -m trunk develop
$ git fetch r1
From ../remote1
 * [new branch]      develop    -> r1/develop
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
$ git config --get-regexp 'remote\.r1\..*'
remote.r1.url ../remote1
remote.r1.fetch +refs/heads/*:refs/remotes/r1/*
Enter fullscreen mode Exit fullscreen mode

That's the actual improvement: one global line, set once, keeps every remote's HEAD current indefinitely, without ever touching that remote's own config block. Before 2.56 the equivalent was one remote.<name>.followRemoteHEAD always line added for every single remote you cared about, by hand, after noticing the problem.

It did nothing on last month's Git

To be sure the global key is genuinely new and not just newly documented, I pointed the same GIT_CONFIG_GLOBAL file at Git 2.55.0, which I'd compiled from the official v2.55.0 tag:

$ git --version
git version 2.55.0
$ git fetch r1          # first fetch, symref doesn't exist yet, 'create' kicks in
 * [new branch]      develop    -> r1/develop
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
$ git --git-dir=../remote1 branch -m develop final
$ git fetch r1          # second fetch, remote renamed again
 * [new branch]      final      -> r1/final
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
Enter fullscreen mode Exit fullscreen mode

Same global config file, same rename, same remote. On 2.55 the second rename leaves HEAD stale at develop, because 2.55 only understands the per-remote key. It doesn't error on the unrecognised global setting, it just never consults it. That first fetch succeeding was the default create behaviour doing its ordinary job on a never-before-seen remote, not the global setting working — which is exactly the mistake I made on my first pass through this test, below.

What it refuses

The per-remote setting accepts one value the global one doesn't: warn-if-not-$branch, which behaves like warn but keeps quiet as long as the remote's HEAD still matches the branch you name. I tried setting that at the global level, since the documentation for remote.<name>.followRemoteHEAD says it accepts "the values supported by fetch.followRemoteHEAD" plus this one, which reads as though it might go either way:

$ git config --global fetch.followRemoteHEAD warn-if-not-main
$ git fetch r3
warning: unrecognized fetch.followRemoteHEAD value 'warn-if-not-main' ignored
From ../remote3
 * [new branch]      newmain    -> r3/newmain
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

No error, no non-zero exit, just a warning and a silent fall-back to create. If you set this in your global config expecting it to suppress warnings for a specific branch name on every remote, it will quietly not do that, and the only sign is a line easy to miss in fetch output you weren't reading closely.

I also checked precedence between the two settings, since the docs state the per-remote value overrides the global one but I wanted to see it fail in both directions:

$ git config --global fetch.followRemoteHEAD never
$ git --git-dir=../remote2 branch -m trunk r2final
$ git fetch r2                                    # no per-remote override: stays stale
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/trunk
$ git config remote.r2.followRemoteHEAD always
$ git --git-dir=../remote2 branch -m r2final r2final2
$ git fetch r2                                    # per-remote override: updates
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/r2final2
Enter fullscreen mode Exit fullscreen mode

That matched the documentation exactly, both ways.

What it costs, and what it doesn't tell you

I timed five fetches each, against a local filesystem remote, with the setting on create and then on always:

mode run 1 run 2 run 3 run 4 run 5
create 11ms 11ms 11ms 11ms 11ms
always 11ms 11ms 12ms 11ms 11ms

No measurable difference. That's a local filesystem transport, so it only tells you the extra ref write costs nothing on top of a fetch that's already happening; it says nothing about network latency, which this setup can't produce.

The more interesting cost is that always mode gives you no feedback when it does something. Compare the two fetches I ran against r3 with always configured:

$ git fetch r3
From ../remote3
 * [new branch]      finalbranch -> r3/finalbranch
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

That output is identical whether or not origin/HEAD just got silently repointed. If you want to know it happened, you have to go and check with git symbolic-ref or git remote show <remote> afterwards; nothing in a normal fetch run says so. For a setting whose entire purpose is to change state you didn't ask this invocation to change, that's worth knowing before you turn it on for every clone a CI job makes.

What I got wrong on the way

My first attempt at testing the global default was to add three brand new remotes, set fetch.followRemoteHEAD always beforehand, fetch all three, and check that all three HEAD refs came out correct with no per-remote config. They did, and for a few minutes I treated that as proof the feature worked.

It wasn't proof of anything. create, the old default, already creates a correct HEAD symref for any remote that doesn't have one yet, with no config at all, on every Git version back to whenever the per-remote setting first existed. Three fresh remotes with no prior HEAD are exactly the case where create and always produce the same result. I only caught this rereading the fetch.followRemoteHEAD documentation a second time, which is what sent me back to re-run the test against a remote that had already been fetched once and then changed its default branch again — the case the old default genuinely can't handle and the new global setting can.

Run it yourself

This needs Git built from source, since Ubuntu's packaged Git (2.43.0 here) predates the per-remote setting entirely. Building takes a few minutes on four cores.

# build 2.56.0 and 2.55.0 for comparison
sudo apt-get install -y build-essential libssl-dev libcurl4-openssl-dev libexpat1-dev gettext zlib1g-dev
git clone --depth 1 --branch v2.56.0 https://github.com/git/git /tmp/git-256
cd /tmp/git-256 && make configure && ./configure --prefix=/opt/git-2.56 && make -j4 && make install

mkdir /tmp/gittest && cd /tmp/gittest
git init --bare -b main remote-a
git clone remote-a seed -q && cd seed && git commit --allow-empty -qm init && git push -q origin main
cd .. && rm -rf seed

GIT=/opt/git-2.56/bin/git
$GIT clone remote-a work -q
git --git-dir=remote-a branch -m main trunk   # simulate a default-branch rename

cd work
$GIT fetch                                     # pulls trunk, origin/HEAD stays on main
$GIT symbolic-ref refs/remotes/origin/HEAD     # -> refs/remotes/origin/main, stale

$GIT config remote.origin.followRemoteHEAD always
git --git-dir=../remote-a branch -m trunk final
$GIT fetch                                     # now it follows
$GIT symbolic-ref refs/remotes/origin/HEAD     # -> refs/remotes/origin/final
Enter fullscreen mode Exit fullscreen mode

If you maintain more than a couple of remotes, or you're responsible for a CI image that clones repositories whose owners might rename master to main out from under you, set fetch.followRemoteHEAD to always once in your global config and stop thinking about it per-remote. Just don't expect warn-if-not-$branch to work there, and don't expect any output when it quietly fixes something for you.

Top comments (0)