]> git.ipfire.org Git - thirdparty/git.git/commitdiff
refs: do not clobber dangling symrefs
authorJeff King <peff@peff.net>
Tue, 19 Aug 2025 19:29:34 +0000 (15:29 -0400)
committerJunio C Hamano <gitster@pobox.com>
Tue, 19 Aug 2025 23:06:02 +0000 (16:06 -0700)
When given an expected "before" state, the ref-writing code will avoid
overwriting any ref that does not match that expected state. We use the
null oid as a sentinel value for "nothing should exist", and likewise
that is the sentinel value we get when trying to read a ref that does
not exist.

But there's one corner case where this is ambiguous: dangling symrefs.
Trying to read them will yield the null oid, but there is potentially
something of value there: the dangling symref itself.

For a normal recursive write, this is OK. Imagine we have a symref
"FOO_HEAD" that points to a ref "refs/heads/bar" that does not exist,
and we try to write to it with a create operation like:

  oid=$(git rev-parse HEAD) ;# or whatever
  git symbolic-ref FOO_HEAD refs/heads/bar
  echo "create FOO_HEAD $oid" | git update-ref --stdin

The attempt to resolve FOO_HEAD will actually resolve "bar", yielding
the null oid. That matches our expectation, and the write proceeds. This
is correct, because we are not writing FOO_HEAD at all, but writing its
destination "bar", which in fact does not exist.

But what if the operation asked not to dereference symrefs? Like this:

  echo "create FOO_HEAD $oid" | git update-ref --no-deref --stdin

Resolving FOO_HEAD would still result in a null oid, and the write will
proceed. But it will overwrite FOO_HEAD itself, removing the fact that
it ever pointed to "bar".

This case is a little esoteric; we are clobbering a symref with a
no-deref write of a regular ref value. But the same problem occurs when
writing symrefs. For example:

  echo "symref-create FOO_HEAD refs/heads/other" |
  git update-ref --no-deref --stdin

The "create" operation asked us to create FOO_HEAD only if it did not
exist. But we silently overwrite the existing value.

You can trigger this without using update-ref via the fetch
followRemoteHEAD code. In "create" mode, it should not overwrite an
existing value. But if you manually create a symref pointing to a value
that does not yet exist (either via symbolic-ref or with "remote add
-m"), create mode will happily overwrite it.

Instead, we should detect this case and refuse to write. The correct
specification to overwrite FOO_HEAD in this case is to provide an
expected target ref value, like:

  echo "symref-update FOO_HEAD refs/heads/other ref refs/heads/bar" |
  git update-ref --no-deref --stdin

Note that the non-symref "update" directive does not allow you to do
this (you can only specify an oid). This is a weakness in the update-ref
interface, and you'd have to overwrite unconditionally, like:

  echo "update FOO_HEAD $oid" | git update-ref --no-deref --stdin

Likewise other symref operations like symref-delete do not accept the
"ref" keyword. You should be able to do:

  echo "symref-delete FOO_HEAD ref refs/heads/bar"

but cannot (and can only delete unconditionally). This patch doesn't
address those gaps. We may want to do so in a future patch for
completeness, but it's not clear if anybody actually wants to perform
those operations. The symref update case (specifically, via
followRemoteHEAD) is what I ran into in the wild.

The code for the fix is relatively straight-forward given the discussion
above. But note that we have to implement it independently for the files
and reftable backends. The "old oid" checks happen as part of the
locking process, which is implemented separately for each system. We may
want to factor this out somehow, but it's beyond the scope of this
patch. (Another curiosity is that the messages in the reftable code are
marked for translation, but the ones in the files backend are not. I
followed local convention in each case, but we may want to harmonize
this at some point).

Signed-off-by: Jeff King <peff@peff.net>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
refs/files-backend.c
refs/reftable-backend.c
t/t1400-update-ref.sh
t/t5510-fetch.sh

index 905555365b8ff0611843e52ea5def4ae2618d6c7..a4419ef62d8db2dbc7798a9db439491a7b72d999 100644 (file)
@@ -2512,13 +2512,37 @@ static enum ref_transaction_error split_symref_update(struct ref_update *update,
  */
 static enum ref_transaction_error check_old_oid(struct ref_update *update,
                                                struct object_id *oid,
+                                               struct strbuf *referent,
                                                struct strbuf *err)
 {
        if (update->flags & REF_LOG_ONLY ||
-           !(update->flags & REF_HAVE_OLD) ||
-           oideq(oid, &update->old_oid))
+           !(update->flags & REF_HAVE_OLD))
                return 0;
 
+       if (oideq(oid, &update->old_oid)) {
+               /*
+                * Normally matching the expected old oid is enough. Either we
+                * found the ref at the expected state, or we are creating and
+                * expect the null oid (and likewise found nothing).
+                *
+                * But there is one exception for the null oid: if we found a
+                * symref pointing to nothing we'll also get the null oid. In
+                * regular recursive mode, that's good (we'll write to what the
+                * symref points to, which doesn't exist). But in no-deref
+                * mode, it means we'll clobber the symref, even though the
+                * caller asked for this to be a creation event. So flag
+                * that case to preserve the dangling symref.
+                */
+               if ((update->flags & REF_NO_DEREF) && referent->len &&
+                   is_null_oid(oid)) {
+                       strbuf_addf(err, "cannot lock ref '%s': "
+                                   "dangling symref already exists",
+                                   ref_update_original_update_refname(update));
+                       return REF_TRANSACTION_ERROR_CREATE_EXISTS;
+               }
+               return 0;
+       }
+
        if (is_null_oid(&update->old_oid)) {
                strbuf_addf(err, "cannot lock ref '%s': "
                            "reference already exists",
@@ -2658,7 +2682,8 @@ static enum ref_transaction_error lock_ref_for_update(struct files_ref_store *re
                        if (update->old_target)
                                ret = ref_update_check_old_target(referent.buf, update, err);
                        else
-                               ret = check_old_oid(update, &lock->old_oid, err);
+                               ret = check_old_oid(update, &lock->old_oid,
+                                                   &referent, err);
                        if (ret)
                                goto out;
                } else {
@@ -2690,7 +2715,8 @@ static enum ref_transaction_error lock_ref_for_update(struct files_ref_store *re
                        ret = REF_TRANSACTION_ERROR_EXPECTED_SYMREF;
                        goto out;
                } else {
-                       ret = check_old_oid(update, &lock->old_oid, err);
+                       ret = check_old_oid(update, &lock->old_oid,
+                                           &referent, err);
                        if  (ret) {
                                goto out;
                        }
index 99fafd75ebe8ff001d30b6d38885c76bbea45219..ef98584bf98978288d4c03751d0d86391104d7bc 100644 (file)
@@ -1272,9 +1272,33 @@ static enum ref_transaction_error prepare_single_update(struct reftable_ref_stor
                ret = ref_update_check_old_target(referent->buf, u, err);
                if (ret)
                        return ret;
-       } else if ((u->flags & (REF_LOG_ONLY | REF_HAVE_OLD)) == REF_HAVE_OLD &&
-                  !oideq(&current_oid, &u->old_oid)) {
-               if (is_null_oid(&u->old_oid)) {
+       } else if ((u->flags & (REF_LOG_ONLY | REF_HAVE_OLD)) == REF_HAVE_OLD) {
+               if (oideq(&current_oid, &u->old_oid)) {
+                       /*
+                        * Normally matching the expected old oid is enough. Either we
+                        * found the ref at the expected state, or we are creating and
+                        * expect the null oid (and likewise found nothing).
+                        *
+                        * But there is one exception for the null oid: if we found a
+                        * symref pointing to nothing we'll also get the null oid. In
+                        * regular recursive mode, that's good (we'll write to what the
+                        * symref points to, which doesn't exist). But in no-deref
+                        * mode, it means we'll clobber the symref, even though the
+                        * caller asked for this to be a creation event. So flag
+                        * that case to preserve the dangling symref.
+                        *
+                        * Everything else is OK and we can fall through to the
+                        * end of the conditional chain.
+                        */
+                       if ((u->flags & REF_NO_DEREF) &&
+                           referent->len &&
+                           is_null_oid(&u->old_oid)) {
+                               strbuf_addf(err, _("cannot lock ref '%s': "
+                                           "dangling symref already exists"),
+                                           ref_update_original_update_refname(u));
+                               return REF_TRANSACTION_ERROR_CREATE_EXISTS;
+                       }
+               } else if (is_null_oid(&u->old_oid)) {
                        strbuf_addf(err, _("cannot lock ref '%s': "
                                           "reference already exists"),
                                    ref_update_original_update_refname(u));
index d29d23cb8905f865e68da0e782c3cbe1948c6c3f..29b31e3b9bda80b2dbd7444c7b2bf6fe1c06104a 100755 (executable)
@@ -2310,4 +2310,25 @@ test_expect_success 'update-ref should also create reflog for HEAD' '
        test_cmp expect actual
 '
 
+test_expect_success 'dangling symref not overwritten by creation' '
+       test_when_finished "git update-ref -d refs/heads/dangling" &&
+       git symbolic-ref refs/heads/dangling refs/heads/does-not-exist &&
+       test_must_fail git update-ref --no-deref --stdin 2>err <<-\EOF &&
+       create refs/heads/dangling HEAD
+       EOF
+       test_grep "cannot lock.*dangling symref already exists" err &&
+       test_must_fail git rev-parse --verify refs/heads/dangling &&
+       test_must_fail git rev-parse --verify refs/heads/does-not-exist
+'
+
+test_expect_success 'dangling symref overwritten without old oid' '
+       test_when_finished "git update-ref -d refs/heads/dangling" &&
+       git symbolic-ref refs/heads/dangling refs/heads/does-not-exist &&
+       git update-ref --no-deref --stdin <<-\EOF &&
+       update refs/heads/dangling HEAD
+       EOF
+       git rev-parse --verify refs/heads/dangling &&
+       test_must_fail git rev-parse --verify refs/heads/does-not-exist
+'
+
 test_done
index 24379ec7aa9ec72d1df6b5cd93113a721df6378b..83d1aadf9f50ed07482db7cea19f9b6576ccca7d 100755 (executable)
@@ -232,6 +232,15 @@ test_expect_success 'followRemoteHEAD does not kick in with refspecs' '
        test_cmp expect actual
 '
 
+test_expect_success 'followRemoteHEAD create does not overwrite dangling symref' '
+       git -C two remote add -m does-not-exist custom-head ../one &&
+       test_config -C two remote.custom-head.followRemoteHEAD create &&
+       git -C two fetch custom-head &&
+       echo refs/remotes/custom-head/does-not-exist >expect &&
+       git -C two symbolic-ref refs/remotes/custom-head/HEAD >actual &&
+       test_cmp expect actual
+'
+
 test_expect_success 'fetch --prune on its own works as expected' '
        git clone . prune &&
        (