{"thread":{"id":"66273","subject":"[PATCH 0/2] push: fix --force-if-includes consulting wrong ref","startedAt":"2026-09-04T21:01:43Z","lastAt":"2026-10-05T21:26:05Z","messageCount":40,"participants":["Tyler Cipriani","Ben Knoble","D. Ben Knoble","Junio C Hamano","Patrick Steinhardt"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"552001","messageId":"20260904210122.431757-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":null,"subject":"[PATCH 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-04T21:01:20Z","receivedAt":"2026-09-04T21:01:43Z","isPatch":true,"body":"--force-if-includes has been checking the reflog of the local branch named\nafter the destination branch regardless of what's being pushed. This can cause\nfalse rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n               false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local branch\ncontains the remote tip but you --force-if-includes push an unrelated branch,\nclobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\nfail against maint, but pass with patches applied.\n\nExisting tests covered refspecs with different names for --force-with-lease,\nbut missed --force-if-includes. New patches cover:\n\n- allow forced-update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch lacking\n  branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n\nOpen question: the detached HEAD case. I opted to reject, since it seems like\nit might be surprising to allow in the case where you were just on a branch\nwithout the the tip of a remote ref, removed the last commit with git checkout\nHEAD^ and pushed with --force-if-includes and it allowed a destructive push.\nI made a separate patch showing different advice for that case (since a\ngit pull won't help).\n\nBased on maint since this is a bugfix. Happy to split patches any way\nthat's helpful.\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n\nTyler Cipriani (2):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes detached HEAD advice\n\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++\n builtin/send-pack.c              |  5 +++\n remote.c                         | 27 +++++++++++-\n remote.h                         | 10 +++--\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-\n transport-helper.c               |  5 +++\n transport.c                      |  8 ++++\n transport.h                      |  1 +\n 12 files changed, 143 insertions(+), 5 deletions(-)\n\n\nbase-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc\n-- \n2.47.3\n\n"},{"id":"552002","messageId":"20260904210122.431757-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-04T21:01:21Z","receivedAt":"2026-09-04T21:01:47Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nThis can cause confusing rejections or unintended data loss.\n\nUsing a command like:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nFalse rejections: when src is an up-to-date branch, but main is\nout-of-date or nonexistent, then the includes check will fail telling\nusers the remote ref has been updated since the last checkout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the if-includes check will allow the push, clobbering\nthe remote main.\n\nFind local reflog using ref->peer_ref. When using a refspec like\nHEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog.\nIn a detached HEAD state, the reflog cannot tell us if the history\nbeing pushed includes the tip of the remote, so the push is rejected.\n\nSkip deletions:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 24 ++++++++++++++++-\n t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 88 insertions(+), 1 deletion(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..326af76eeb 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n+\tstruct ref *local;\n+\tconst char *name;\n+\tint flag;\n+\n+\tif (!remote->peer_ref)\n+\t\treturn;\n+\n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = remote->peer_ref->name;\n+\tif (!strcmp(name, \"HEAD\")) {\n+\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n+\t\tif (!name || !(flag & REF_ISSYMREF)) {\n+\t\t\t/* detached HEAD: no per-branch reflog to consult */\n+\t\t\tremote->unreachable = 1;\n+\t\t\treturn;\n+\t\t}\n+\t}\n+\n+\tlocal = get_local_ref(name);\n \tif (!local)\n \t\treturn;\n \ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..0c02151747 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552003","messageId":"20260904210122.431757-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-04T21:01:22Z","receivedAt":"2026-09-04T21:01:54Z","isPatch":true,"body":"When a --force-if-includes push is rejected due to a detached HEAD\nstate where there is no per-branch reflog to consult, the advice is\nmisleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate between a detached HEAD rejection\nvs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              |  7 +++++--\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 57 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..a0eff8bbd6 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is available locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..9676c6241f 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,12 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because the tip of the remote-tracking branch\\n\"\n+\t   \"cannot be checked against a detached HEAD. If you want to push anyway,\\n\"\n+\t   \"specify the expected value with '--force-with-lease=<ref>:<expect>'\\n\"\n+\t   \"or use '--no-force-if-includes' to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +367,13 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +425,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 326af76eeb..72bfc4dbc4 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n \t\tif (!name || !(flag & REF_ISSYMREF)) {\n \t\t\t/* detached HEAD: no per-branch reflog to consult */\n-\t\t\tremote->unreachable = 1;\n+\t\t\tremote->unverifiable = 1;\n \t\t\treturn;\n \t\t}\n \t}\ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 0c02151747..fe6af3f41c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -457,7 +458,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552052","messageId":"D37B05ED-1B23-4B05-8B4B-EA770C85E0F3@gmail.com","threadId":"66273","inReplyTo":"20260904210122.431757-2-tyler@tylercipriani.com","subject":"Re: [PATCH 1/2] push: check pushed ref for --force-if-includes","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-05T18:57:03Z","receivedAt":"2026-09-05T18:57:15Z","isPatch":true,"body":"\n> Le 4 sept. 2026 à 17:01, Tyler Cipriani <tyler@tylercipriani.com> a écrit :\n> \n> ﻿\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\n> reachable from one of the 'reflog' entries of the local branch.\"\n> \n> But check_if_includes_upstream() uses the local per-branch reflog based\n> on the destination branch rather than the branch being pushed; using\n> ref->name vs. ref->peer_ref->name.\n> \n> This can cause confusing rejections or unintended data loss.\n> \n> Using a command like:\n> \n>  git push --force-if-includes --force-with-lease origin src:main\n> \n> False rejections: when src is an up-to-date branch, but main is\n> out-of-date or nonexistent, then the includes check will fail telling\n> users the remote ref has been updated since the last checkout.\n> \n> Data loss: when src is an orphan/out-dated branch, but main is\n> up-to-date, then the if-includes check will allow the push, clobbering\n> the remote main.\n\nHm. This case *could* be by design, to rewind and potentially\nmodify a remote branch, discarding new work I’ve already checked.\n\nBut the includes check is about reminding to do such a check.\nSo failing and requiring me to bypass the check seems ok.\n\n> Find local reflog using ref->peer_ref. When using a refspec like\n> HEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog.\n> In a detached HEAD state, the reflog cannot tell us if the history\n> being pushed includes the tip of the remote, so the push is rejected.\n\nThis seems to be what I reported in the mail your cover\nletter cites. So, am I reading correctly that this is no change\nfrom current behavior?\n\n…ah, patch 2 addresses that specifically. Which, I now\nremember you said in the cover as well. Oops.\n\nIt *could* be worth clarifying in the proposed log message that we are only preserving behavior here, but that’s a very small nit.\n\n> Skip deletions:\n> \n>  git push --force-if-includes --force-with-lease origin :main\n> \n> ref->deletion is set after apply_push_cas (which triggers\n> check_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\n> Instead check with is_null_oid to detect and allow deletion.\n> \n> Reported-by: Stefan Haller <lists@haller-berlin.de>\n> Reported-by: D. Ben Knoble <ben.knoble@gmail.com>\n> Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n> ---\n> remote.c            | 24 ++++++++++++++++-\n> t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++\n> 2 files changed, 88 insertions(+), 1 deletion(-)\n> \n> diff --git a/remote.c b/remote.c\n> index 00723b385e..326af76eeb 100644\n> --- a/remote.c\n> +++ b/remote.c\n> @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n> */\n> static void check_if_includes_upstream(struct ref *remote)\n> {\n> -    struct ref *local = get_local_ref(remote->name);\n> +    struct ref *local;\n> +    const char *name;\n> +    int flag;\n> +\n> +    if (!remote->peer_ref)\n> +        return;\n> +\n> +    /* A deletion has no local history to check against. */\n> +    if (is_null_oid(&remote->peer_ref->new_oid))\n> +        return;\n> +\n> +    name = remote->peer_ref->name;\n> +    if (!strcmp(name, \"HEAD\")) {\n> +        name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n> +                           \"HEAD\", 0, NULL, &flag);\n> +        if (!name || !(flag & REF_ISSYMREF)) {\n> +            /* detached HEAD: no per-branch reflog to consult */\n> +            remote->unreachable = 1;\n> +            return;\n> +        }\n> +    }\n> +\n> +    local = get_local_ref(name);\n>  if (!local)\n>      return;\n> \n> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\n> index cba26a872d..0c02151747 100755\n> --- a/t/t5533-push-cas.sh\n> +++ b/t/t5533-push-cas.sh\n> @@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n>  )\n> '\n> \n> +test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n> +    setup_src_dup_dst &&\n> +    test_when_finished \"rm -fr dst src dup\" &&\n> +    (\n> +        cd src &&\n> +        git fetch &&\n> +        git switch -c newbranch origin/main &&\n> +        git rebase HEAD --onto HEAD^ &&\n> +        git push --force-if-includes --force-with-lease origin newbranch:main\n> +    )\n> +'\n> +test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n> +    setup_src_dup_dst &&\n> +    test_when_finished \"rm -fr dst src dup\" &&\n> +    (\n> +        cd src &&\n> +        git fetch &&\n> +        git switch -c newbranch origin/main &&\n> +        git rebase HEAD --onto HEAD^ &&\n> +        git push --force-if-includes --force-with-lease origin HEAD:main\n> +    )\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n> +    setup_src_dup_dst &&\n> +    test_when_finished \"rm -fr dst src dup\" &&\n> +    (\n> +        cd src &&\n> +        git fetch &&\n> +        git switch main &&\n> +        git reset --hard origin/main &&\n> +        git switch --orphan orphan &&\n> +        test_commit I &&\n> +        test_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n> +    )\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n> +    setup_src_dup_dst &&\n> +    test_when_finished \"rm -fr dst src dup\" &&\n> +    (\n> +        cd src &&\n> +        git fetch &&\n> +        git switch main &&\n> +        git reset --hard origin/main &&\n> +        git switch --orphan orphan &&\n> +        test_commit I &&\n> +        test_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n> +    )\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n> +    setup_src_dup_dst &&\n> +    test_when_finished \"rm -fr dst src dup\" &&\n> +    (\n> +        cd src &&\n> +        git fetch &&\n> +        git switch main &&\n> +        git reset --hard origin/main &&\n> +        git switch -c newbranch origin/main &&\n> +        git checkout HEAD^ &&\n> +        test_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n> +    )\n> +'\n> +\n> test_done\n> --\n> 2.47.3\n"},{"id":"552053","messageId":"D798198C-5F97-4701-9050-7868B6482214@gmail.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"Re: [PATCH 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-05T18:59:25Z","receivedAt":"2026-09-05T18:59:38Z","isPatch":true,"body":"\n> Le 4 sept. 2026 à 17:01, Tyler Cipriani <tyler@tylercipriani.com> a écrit :\n> \n> ﻿--force-if-includes has been checking the reflog of the local branch named\n> after the destination branch regardless of what's being pushed. This can cause\n> false rejections or unintended data loss.\n> \n> False rejection has been reported twice that I could find:\n> \n> - 2023-07-26 - Stefan Haller reported local branch with a different name\n>              false rejection[0]\n> - 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nAha. I’d nearly forgotten that mail, and have since adjusted to\nsome intuition of when to force-if-includes.\n\nI’d be grateful to not need such potentially-buggy intuition :)\n\n> The same root cause can result in data loss: when a same-name local branch\n> contains the remote tip but you --force-if-includes push an unrelated branch,\n> clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\n> fail against maint, but pass with patches applied.\n> \n> Existing tests covered refspecs with different names for --force-with-lease,\n> but missed --force-if-includes. New patches cover:\n> \n> - allow forced-update using refspec with different-named local branch\n> - allow same as above, but with HEAD\n> - reject force-update using refspec with different-named local branch lacking\n> branch tip\n> - reject same as above using HEAD\n> - reject detached HEAD\n> \n> Open question: the detached HEAD case. I opted to reject, since it seems like\n> it might be surprising to allow in the case where you were just on a branch\n> without the the tip of a remote ref, removed the last commit with git checkout\n> HEAD^ and pushed with --force-if-includes and it allowed a destructive push.\n> I made a separate patch showing different advice for that case (since a\n> git pull won't help).\n> \n> Based on maint since this is a bugfix. Happy to split patches any way\n> that's helpful.\n> \n> [0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n> \n> Tyler Cipriani (2):\n> push: check pushed ref for --force-if-includes\n> push: fix --force-if-includes detached HEAD advice\n\nThanks for the advice changes! One small nit on the first\npatch you can ignore if you choose.\n\nAt first I hoped we might be able to stop rejecting detached\nHEAD pushes, but some further thought begs the question:\nwhat reflog would we use?\nHEAD’s is too broad :)\n\nSo this may be all we can do for now.\n\nAt least I can replace my intuition with reading the error message again. \n"},{"id":"552085","messageId":"CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com","threadId":"66273","inReplyTo":"D798198C-5F97-4701-9050-7868B6482214@gmail.com","subject":"Re: [PATCH 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-06T20:24:10Z","receivedAt":"2026-09-06T20:24:22Z","isPatch":true,"body":"On Sat, Sep 5, 2026 at 12:59 PM Ben Knoble <ben.knoble@gmail.com> wrote:\n> Thanks for the advice changes! One small nit on the first\n> patch you can ignore if you choose.\n\nGood call on updating the log message for PATCH 1/2. I'll note that\ndetached HEAD\nis already rejected in v2.\n\n> At first I hoped we might be able to stop rejecting detached\n> HEAD pushes, but some further thought begs the question:\n> what reflog would we use?\n> HEAD’s is too broad :)\n>\n> So this may be all we can do for now.\n\nIt looks like that's the conclusion they reached on the original patchset, too,\nbased on my re-reading of the thread[0]. HEAD's reflog is too broad for the\n--force-if-includes check (with the acknowledged downside being that\n--force-if-includes isn't useful for the detached HEAD case.)\n\n[0]: <https://lore.kernel.org/git/xmqqsgbdk69b.fsf@gitster.c.googlers.com/>\n\n> At least I can replace my intuition with reading the error message again.\n\n:)\n\nThank you for the review!\n"},{"id":"552267","messageId":"20260908222056.1150748-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v2 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-08T22:20:54Z","receivedAt":"2026-09-08T22:21:35Z","isPatch":true,"body":"Changes since v1:\n\n- Clarify in log message 1/2 that --force-if-includes will reject a\n  detached HEAD today (when the same-named local branch lacks the remote\n  tip). And note that this change makes it explicit to always reject\n  the detached HEAD case.\n\n--force-if-includes has been checking the reflog of the local branch named\nafter the destination branch regardless of what's being pushed. This can cause\nfalse rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n               false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local branch\ncontains the remote tip but you --force-if-includes push an unrelated branch,\nclobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\nfail against maint, but pass with patches applied.\n\nExisting tests covered refspecs with different names for --force-with-lease,\nbut missed --force-if-includes. New patches cover:\n\n- allow forced-update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch lacking\n  branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n\nResolved question: the detached HEAD case; HEAD's reflog was considered\nand rejected as too broad for purpose in the original review. cf. [2]\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n[2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>\n\nTyler Cipriani (2):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes detached HEAD advice\n\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++\n builtin/send-pack.c              |  5 +++\n remote.c                         | 27 +++++++++++-\n remote.h                         | 10 +++--\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-\n transport-helper.c               |  5 +++\n transport.c                      |  8 ++++\n transport.h                      |  1 +\n 12 files changed, 143 insertions(+), 5 deletions(-)\n\nRange-diff against v1:\n1:  5e866b883e ! 1:  da27c421ed push: check pushed ref for --force-if-includes\n    @@ Commit message\n         the remote main.\n     \n         Find local reflog using ref->peer_ref. When using a refspec like\n    -    HEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog.\n    -    In a detached HEAD state, the reflog cannot tell us if the history\n    -    being pushed includes the tip of the remote, so the push is rejected.\n    +    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n    +    branch's reflog.\n    +\n    +    But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\n    +    reject the push. HEAD's reflog is too broad to tell us if the history\n    +    being pushed includes the tip of the remote. Rejecting a detached HEAD\n    +    already happens today (if the same-named local branch lacks the remote\n    +    tip); now the detached HEAD state is explicitly rejected.\n     \n         Skip deletions:\n     \n2:  4ae40db7fe = 2:  e07d16d53e push: fix --force-if-includes detached HEAD advice\n\nbase-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc\n-- \n2.47.3\n\n"},{"id":"552268","messageId":"20260908222056.1150748-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v2 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-08T22:20:55Z","receivedAt":"2026-09-08T22:21:44Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nThis can cause confusing rejections or unintended data loss.\n\nUsing a command like:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nFalse rejections: when src is an up-to-date branch, but main is\nout-of-date or nonexistent, then the includes check will fail telling\nusers the remote ref has been updated since the last checkout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the if-includes check will allow the push, clobbering\nthe remote main.\n\nFind local reflog using ref->peer_ref. When using a refspec like\nHEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\nbranch's reflog.\n\nBut if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\nreject the push. HEAD's reflog is too broad to tell us if the history\nbeing pushed includes the tip of the remote. Rejecting a detached HEAD\nalready happens today (if the same-named local branch lacks the remote\ntip); now the detached HEAD state is explicitly rejected.\n\nSkip deletions:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 24 ++++++++++++++++-\n t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 88 insertions(+), 1 deletion(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..326af76eeb 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n+\tstruct ref *local;\n+\tconst char *name;\n+\tint flag;\n+\n+\tif (!remote->peer_ref)\n+\t\treturn;\n+\n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = remote->peer_ref->name;\n+\tif (!strcmp(name, \"HEAD\")) {\n+\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n+\t\tif (!name || !(flag & REF_ISSYMREF)) {\n+\t\t\t/* detached HEAD: no per-branch reflog to consult */\n+\t\t\tremote->unreachable = 1;\n+\t\t\treturn;\n+\t\t}\n+\t}\n+\n+\tlocal = get_local_ref(name);\n \tif (!local)\n \t\treturn;\n \ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..0c02151747 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552269","messageId":"20260908222056.1150748-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v2 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-08T22:20:56Z","receivedAt":"2026-09-08T22:21:48Z","isPatch":true,"body":"When a --force-if-includes push is rejected due to a detached HEAD\nstate where there is no per-branch reflog to consult, the advice is\nmisleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate between a detached HEAD rejection\nvs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              |  7 +++++--\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 57 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..a0eff8bbd6 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is available locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..9676c6241f 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,12 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because the tip of the remote-tracking branch\\n\"\n+\t   \"cannot be checked against a detached HEAD. If you want to push anyway,\\n\"\n+\t   \"specify the expected value with '--force-with-lease=<ref>:<expect>'\\n\"\n+\t   \"or use '--no-force-if-includes' to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +367,13 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +425,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 326af76eeb..72bfc4dbc4 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n \t\tif (!name || !(flag & REF_ISSYMREF)) {\n \t\t\t/* detached HEAD: no per-branch reflog to consult */\n-\t\t\tremote->unreachable = 1;\n+\t\t\tremote->unverifiable = 1;\n \t\t\treturn;\n \t\t}\n \t}\ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 0c02151747..fe6af3f41c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -457,7 +458,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552327","messageId":"CALnO6CBY32FpDoN5hTA_NK9eRKV-rVJ6BS=+8H1GEz_wNbHbYA@mail.gmail.com","threadId":"66273","inReplyTo":"20260908222056.1150748-1-tyler@tylercipriani.com","subject":"Re: [PATCH v2 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-09T11:59:26Z","receivedAt":"2026-09-09T11:59:38Z","isPatch":true,"body":"On Tue, Sep 8, 2026 at 6:21 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n>\n> Changes since v1:\n>\n> - Clarify in log message 1/2 that --force-if-includes will reject a\n>   detached HEAD today (when the same-named local branch lacks the remote\n>   tip). And note that this change makes it explicit to always reject\n>   the detached HEAD case.\n\nThanks!\n"},{"id":"552481","messageId":"xmqqld99dk20.fsf@gitster.g","threadId":"66273","inReplyTo":"20260908222056.1150748-2-tyler@tylercipriani.com","subject":"Re: [PATCH v2 1/2] push: check pushed ref for --force-if-includes","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-10T18:43:03Z","receivedAt":"2026-09-10T18:43:06Z","isPatch":true,"body":"Tyler Cipriani <tyler@tylercipriani.com> writes:\n\n> Message-ID: <20260908222056.1150748-2-tyler@tylercipriani.com>\n> References: <20260904210122.431757-1-tyler@tylercipriani.com>\n\nThis is incorrectly threaded.  It is not made as a reply to the\ncover letter of v2; it is a reply to the cover letter of the initial\niteration, and breaks automation.\n\nThe same problem exists for [v2 2/2] as well.\n"},{"id":"552492","messageId":"CAHLx=Ok_0RntxYo5GsEQTmvxWy7B7S8KDHq=G+G5jWorPz3-3Q@mail.gmail.com","threadId":"66273","inReplyTo":"xmqqld99dk20.fsf@gitster.g","subject":"Re: [PATCH v2 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-10T22:08:29Z","receivedAt":"2026-09-10T22:08:41Z","isPatch":true,"body":"On Thu, Sep 10, 2026 at 12:43 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Tyler Cipriani <tyler@tylercipriani.com> writes:\n>\n> > Message-ID: <20260908222056.1150748-2-tyler@tylercipriani.com>\n> > References: <20260904210122.431757-1-tyler@tylercipriani.com>\n>\n> This is incorrectly threaded.  It is not made as a reply to the\n> cover letter of v2; it is a reply to the cover letter of the initial\n> iteration, and breaks automation.\n>\n> The same problem exists for [v2 2/2] as well.\n\nSorry for that. I had --in-reply-to on format-patch vs. send-email.\nI'll send a v3 with correct shallow threading.\n"},{"id":"552493","messageId":"20260910230506.1631656-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v3 0/2] push: fix --force-if-includes consulting wrong ref","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-10T23:05:04Z","receivedAt":"2026-09-10T23:05:21Z","isPatch":true,"body":"Changes since v2:\n\n- Correct patch threading of 1/2 and 2/2 to reply to cover letter of\n  current patchset vs. cover letter of the initial iteration.\n\nChanges since v1:\n\n- Clarify in log message 1/2 that --force-if-includes will reject a\n  detached HEAD today (when the same-named local branch lacks the remote\n  tip). And note that this change makes it explicit to always reject\n  the detached HEAD case.\n\n--force-if-includes has been checking the reflog of the local branch named\nafter the destination branch regardless of what's being pushed. This can cause\nfalse rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n               false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local branch\ncontains the remote tip but you --force-if-includes push an unrelated branch,\nclobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\nfail against maint, but pass with patches applied.\n\nExisting tests covered refspecs with different names for --force-with-lease,\nbut missed --force-if-includes. New patches cover:\n\n- allow forced-update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch lacking\n  branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n\nResolved question: the detached HEAD case; HEAD's reflog was considered\nand rejected as too broad for purpose in the original review. cf. [2]\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n[2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>\n\nTyler Cipriani (2):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes detached HEAD advice\n\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++\n builtin/send-pack.c              |  5 +++\n remote.c                         | 27 +++++++++++-\n remote.h                         | 10 +++--\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-\n transport-helper.c               |  5 +++\n transport.c                      |  8 ++++\n transport.h                      |  1 +\n 12 files changed, 143 insertions(+), 5 deletions(-)\n\nRange-diff against v2:\n1:  da27c421ed = 1:  da27c421ed push: check pushed ref for --force-if-includes\n2:  e07d16d53e = 2:  e07d16d53e push: fix --force-if-includes detached HEAD advice\n-- \n2.47.3\n\n"},{"id":"552494","messageId":"20260910230506.1631656-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260910230506.1631656-1-tyler@tylercipriani.com","subject":"[PATCH v3 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-10T23:05:05Z","receivedAt":"2026-09-10T23:05:36Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nThis can cause confusing rejections or unintended data loss.\n\nUsing a command like:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nFalse rejections: when src is an up-to-date branch, but main is\nout-of-date or nonexistent, then the includes check will fail telling\nusers the remote ref has been updated since the last checkout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the if-includes check will allow the push, clobbering\nthe remote main.\n\nFind local reflog using ref->peer_ref. When using a refspec like\nHEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\nbranch's reflog.\n\nBut if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\nreject the push. HEAD's reflog is too broad to tell us if the history\nbeing pushed includes the tip of the remote. Rejecting a detached HEAD\nalready happens today (if the same-named local branch lacks the remote\ntip); now the detached HEAD state is explicitly rejected.\n\nSkip deletions:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 24 ++++++++++++++++-\n t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 88 insertions(+), 1 deletion(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..326af76eeb 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n+\tstruct ref *local;\n+\tconst char *name;\n+\tint flag;\n+\n+\tif (!remote->peer_ref)\n+\t\treturn;\n+\n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = remote->peer_ref->name;\n+\tif (!strcmp(name, \"HEAD\")) {\n+\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n+\t\tif (!name || !(flag & REF_ISSYMREF)) {\n+\t\t\t/* detached HEAD: no per-branch reflog to consult */\n+\t\t\tremote->unreachable = 1;\n+\t\t\treturn;\n+\t\t}\n+\t}\n+\n+\tlocal = get_local_ref(name);\n \tif (!local)\n \t\treturn;\n \ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..0c02151747 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552495","messageId":"20260910230506.1631656-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260910230506.1631656-1-tyler@tylercipriani.com","subject":"[PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-10T23:05:06Z","receivedAt":"2026-09-10T23:05:40Z","isPatch":true,"body":"When a --force-if-includes push is rejected due to a detached HEAD\nstate where there is no per-branch reflog to consult, the advice is\nmisleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate between a detached HEAD rejection\nvs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 15 +++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              |  7 +++++--\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 57 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..a0eff8bbd6 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is available locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..9676c6241f 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,12 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because the tip of the remote-tracking branch\\n\"\n+\t   \"cannot be checked against a detached HEAD. If you want to push anyway,\\n\"\n+\t   \"specify the expected value with '--force-with-lease=<ref>:<expect>'\\n\"\n+\t   \"or use '--no-force-if-includes' to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +367,13 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +425,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 326af76eeb..72bfc4dbc4 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n \t\tif (!name || !(flag & REF_ISSYMREF)) {\n \t\t\t/* detached HEAD: no per-branch reflog to consult */\n-\t\t\tremote->unreachable = 1;\n+\t\t\tremote->unverifiable = 1;\n \t\t\treturn;\n \t\t}\n \t}\ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 0c02151747..fe6af3f41c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -457,7 +458,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552518","messageId":"aqOlx5dlprfc0bdO@pks.im","threadId":"66273","inReplyTo":"20260910230506.1631656-2-tyler@tylercipriani.com","subject":"Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-11T06:55:03Z","receivedAt":"2026-09-11T06:55:17Z","isPatch":true,"body":"On Thu, Sep 10, 2026 at 05:05:05PM -0600, Tyler Cipriani wrote:\n> \"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\n> reachable from one of the 'reflog' entries of the local branch.\"\n> \n> But check_if_includes_upstream() uses the local per-branch reflog based\n> on the destination branch rather than the branch being pushed; using\n> ref->name vs. ref->peer_ref->name.\n\nSo... in a `git push origin foo:bar` we look up the reflog for \"bar\" and\nnot \"foo\"?\n\n> This can cause confusing rejections or unintended data loss.\n> \n> Using a command like:\n> \n>     git push --force-if-includes --force-with-lease origin src:main\n> \n> False rejections: when src is an up-to-date branch, but main is\n> out-of-date or nonexistent, then the includes check will fail telling\n> users the remote ref has been updated since the last checkout.\n\nHm. \"up-to-date branch\" in relation to what? You mean if we had commits\nA, B and C, with C being the most recent commit, then \"src\" points to C\nand \"main\" points to B?\n\n> Data loss: when src is an orphan/out-dated branch, but main is\n> up-to-date, then the if-includes check will allow the push, clobbering\n> the remote main.\n\nRight, here \"src\" would point to B and \"main\" would point to C.\n\n> Find local reflog using ref->peer_ref. When using a refspec like\n> HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n> branch's reflog.\n> \n> But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\n> reject the push. HEAD's reflog is too broad to tell us if the history\n> being pushed includes the tip of the remote. Rejecting a detached HEAD\n> already happens today (if the same-named local branch lacks the remote\n> tip); now the detached HEAD state is explicitly rejected.\n\nMakes sense.\n\n> Skip deletions:\n> \n>     git push --force-if-includes --force-with-lease origin :main\n> \n> ref->deletion is set after apply_push_cas (which triggers\n> check_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\n> Instead check with is_null_oid to detect and allow deletion.\n\nThis part feels a bit off to me. Deletions are the most risky operation\nthat we can do, so why would we want to just blindly allow them? There\nmay be good reasons for this, but if so those should be documented as\npart of the commit message. It would probably even be sufficient to say\n\"it has worked this way before, and we don't want to break that case\".\n\n> diff --git a/remote.c b/remote.c\n> index 00723b385e..326af76eeb 100644\n> --- a/remote.c\n> +++ b/remote.c\n> @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n>   */\n>  static void check_if_includes_upstream(struct ref *remote)\n>  {\n> -\tstruct ref *local = get_local_ref(remote->name);\n> +\tstruct ref *local;\n> +\tconst char *name;\n> +\tint flag;\n> +\n> +\tif (!remote->peer_ref)\n> +\t\treturn;\n> +\n> +\t/* A deletion has no local history to check against. */\n> +\tif (is_null_oid(&remote->peer_ref->new_oid))\n> +\t\treturn;\n> +\n> +\tname = remote->peer_ref->name;\n> +\tif (!strcmp(name, \"HEAD\")) {\n> +\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n> +\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n\nShouldn't we pass `RESOLVE_REF_READING | RESOLVE_REF_NO_RECURSE` here?\nOtherwise, the function will return \"HEAD\" even if it could not be\nresolved, and we don't want to recursively resolve symrefs, either.\n\nAlso, is it sufficient to single out \"HEAD\" here? It could for example\nbe that the user passes \"HEAD~\", an object ID or really any other\nrevision, and these should probably not be considered reachable, either,\nright?\n\nMaybe we should instead verify whether this names a local reference and,\nif so, resolve potential symrefs to their target.\n\n> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\n> index cba26a872d..0c02151747 100755\n> --- a/t/t5533-push-cas.sh\n> +++ b/t/t5533-push-cas.sh\n> @@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n>  \t)\n>  '\n>  \n> +test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch -c newbranch origin/main &&\n> +\t\tgit rebase HEAD --onto HEAD^ &&\n> +\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n> +\t)\n> +'\n\nNit: missing empty line between these two tests.\n\nPatrick\n"},{"id":"552519","messageId":"aqOl0SnPHa5iM_tz@pks.im","threadId":"66273","inReplyTo":"20260910230506.1631656-3-tyler@tylercipriani.com","subject":"Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-11T06:55:13Z","receivedAt":"2026-09-11T06:55:23Z","isPatch":true,"body":"On Thu, Sep 10, 2026 at 05:05:06PM -0600, Tyler Cipriani wrote:\n> diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\n> index 257db58918..a0eff8bbd6 100644\n> --- a/Documentation/config/advice.adoc\n> +++ b/Documentation/config/advice.adoc\n> @@ -90,6 +90,10 @@ all advice messages.\n>  \t\tShown when linkgit:git-push[1] rejects a forced update of\n>  \t\ta branch when its remote-tracking ref has updates that we\n>  \t\tdo not have locally.\n> +\tpushRefUnverifiable::\n> +\t\tShown when linkgit:git-push[1] rejects a forced update of\n> +\t\ta branch when we are unable to verify the remote-tracking\n> +\t\tref is available locally.\n\nWe don't really care about the ref being available, but rather about it\nbeing integrated, right? So maybe s/available/integrated/.\n\nPatrick\n"},{"id":"552562","messageId":"xmqq4ifverdh.fsf@gitster.g","threadId":"66273","inReplyTo":"20260910230506.1631656-2-tyler@tylercipriani.com","subject":"Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-11T15:31:54Z","receivedAt":"2026-09-11T15:31:57Z","isPatch":true,"body":"Tyler Cipriani <tyler@tylercipriani.com> writes:\n\n>  static void check_if_includes_upstream(struct ref *remote)\n>  {\n> -\tstruct ref *local = get_local_ref(remote->name);\n> +\tstruct ref *local;\n> +\tconst char *name;\n> +\tint flag;\n> +\n> +\tif (!remote->peer_ref)\n> +\t\treturn;\n\nThis function signals its displeasure by setting remote->unreachble\nto true, so any early return means it is OK to force the push, right?\n\nWhat is the significance of remote not having peer_ref?  Is it a\nusage error (i.e., push is not updating anything over there, and it\nmakes me wonder what the command line to do so looks like)?  Is it a\nprogramming error (i.e., if we are pushing to update no remote ref,\nthis function should never be called)?  If the latter, I wonder if\nBUG() is more appropriate.\n\n> +\t/* A deletion has no local history to check against. */\n> +\tif (is_null_oid(&remote->peer_ref->new_oid))\n> +\t\treturn;\n\nThe comment for this condition is clear.  If we are pushing to\ndelete, checking if our side once used to build on top of theirs\ndoes not guarantee us anything, so we accept the loss of history.\n\n> +\tname = remote->peer_ref->name;\n> +\tif (!strcmp(name, \"HEAD\")) {\n> +\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n> +\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n> +\t\tif (!name || !(flag & REF_ISSYMREF)) {\n> +\t\t\t/* detached HEAD: no per-branch reflog to consult */\n> +\t\t\tremote->unreachable = 1;\n> +\t\t\treturn;\n> +\t\t}\n> +\t}\n> +\n> +\tlocal = get_local_ref(name);\n>  \tif (!local)\n>  \t\treturn;\n\nThe same question here.\n\nAre any of these silent \"punt\" returns tested below?  It does not\nseem to add a new test about pushing-to-delete.\n\nThanks.\n\n> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\n> index cba26a872d..0c02151747 100755\n> --- a/t/t5533-push-cas.sh\n> +++ b/t/t5533-push-cas.sh\n> @@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n>  \t)\n>  '\n>  \n> +test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch -c newbranch origin/main &&\n> +\t\tgit rebase HEAD --onto HEAD^ &&\n> +\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n> +\t)\n> +'\n> +test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch -c newbranch origin/main &&\n> +\t\tgit rebase HEAD --onto HEAD^ &&\n> +\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n> +\t)\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch main &&\n> +\t\tgit reset --hard origin/main &&\n> +\t\tgit switch --orphan orphan &&\n> +\t\ttest_commit I &&\n> +\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n> +\t)\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch main &&\n> +\t\tgit reset --hard origin/main &&\n> +\t\tgit switch --orphan orphan &&\n> +\t\ttest_commit I &&\n> +\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n> +\t)\n> +'\n> +\n> +test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n> +\tsetup_src_dup_dst &&\n> +\ttest_when_finished \"rm -fr dst src dup\" &&\n> +\t(\n> +\t\tcd src &&\n> +\t\tgit fetch &&\n> +\t\tgit switch main &&\n> +\t\tgit reset --hard origin/main &&\n> +\t\tgit switch -c newbranch origin/main &&\n> +\t\tgit checkout HEAD^ &&\n> +\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n> +\t)\n> +'\n> +\n>  test_done\n"},{"id":"552563","messageId":"xmqqtsnvdcdz.fsf@gitster.g","threadId":"66273","inReplyTo":"20260910230506.1631656-3-tyler@tylercipriani.com","subject":"Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-11T15:40:56Z","receivedAt":"2026-09-11T15:40:59Z","isPatch":true,"body":"Tyler Cipriani <tyler@tylercipriani.com> writes:\n\n> When a --force-if-includes push is rejected due to a detached HEAD\n> state where there is no per-branch reflog to consult, the advice is\n> misleading:\n>\n>      ! [rejected] HEAD -> main (remote ref updated since checkout)\n>     error: failed to push some refs to '<remote>'\n>     hint: Updates were rejected because the tip of the remote-tracking\n>     hint: branch has been updated since the last checkout. If you want\n>     hint: to integrate the remote changes, use 'git pull' before\n>     hint: pushing again. See the 'Note about fast-forwards' in 'git\n>     hint: push --help' for details.\n>\n> But a `git pull` will not fix this rejection. What is required is either\n>\n> - Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n> - Ignore the error with --no-force-if-includes\n>\n> Add ref->unverifiable to differentiate between a detached HEAD rejection\n> vs. a remote update rejection.\n\nMakes sense.\n\n> diff --git a/builtin/push.c b/builtin/push.c\n> index 6021b71d66..9676c6241f 100644\n> --- a/builtin/push.c\n> +++ b/builtin/push.c\n> @@ -319,6 +319,12 @@ static const char message_advice_ref_needs_update[] =\n>  \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n>  \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n>  \n> +static const char message_advice_ref_unverifiable[] =\n> +\tN_(\"Updates were rejected because the tip of the remote-tracking branch\\n\"\n> +\t   \"cannot be checked against a detached HEAD. If you want to push anyway,\\n\"\n> +\t   \"specify the expected value with '--force-with-lease=<ref>:<expect>'\\n\"\n> +\t   \"or use '--no-force-if-includes' to skip this check.\");\n\nGood.\n\n> +static void advise_ref_unverifiable(void)\n> +{\n> +\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n> +\t\treturn;\n\nLine that is over +100 column wide?\n\n> +\tadvise(_(message_advice_ref_unverifiable));\n> +}\n\nThis is a tangent, but on a separate thread we were talking about\nconsolidating a sequence\n\n    if (advice_enabled(ADVICE_FOO))\n\tadvise(_(message for FOO));\n\ninto\n\n    advise_if_enabled(ADVICE_FOO, _(message for FOO));\n\nThis is an example of usage that falls outside of the pattern (not a\nbad thing; just what those who advocate more use of advise_if_enabled()\nneed to be aware of).\n\n> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\n> index 0c02151747..fe6af3f41c 100755\n> --- a/t/t5533-push-cas.sh\n> +++ b/t/t5533-push-cas.sh\n> @@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n>  \t\tgit switch main &&\n>  \t\ttest_commit J &&\n>  \t\tgit fetch --all &&\n> -\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n> +\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n> +\t\ttest_grep \"remote ref updated since checkout\" err\n>  \t) &&\n>  \tgit ls-remote dst refs/heads/main >actual.main &&\n>  \tgit ls-remote dst refs/heads/branch >actual.branch &&\n> @@ -457,7 +458,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n>  \t\tgit reset --hard origin/main &&\n>  \t\tgit switch -c newbranch origin/main &&\n>  \t\tgit checkout HEAD^ &&\n> -\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n> +\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n> +\t\ttest_grep \"remote ref unverifiable\" err &&\n> +\t\ttest_grep \"no-force-if-includes\" err\n>  \t)\n>  '\n\nGreat.\n"},{"id":"552567","messageId":"xmqqcxujdbcn.fsf@gitster.g","threadId":"66273","inReplyTo":"aqOl0SnPHa5iM_tz@pks.im","subject":"Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-11T16:03:20Z","receivedAt":"2026-09-11T16:03:23Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Thu, Sep 10, 2026 at 05:05:06PM -0600, Tyler Cipriani wrote:\n>> diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\n>> index 257db58918..a0eff8bbd6 100644\n>> --- a/Documentation/config/advice.adoc\n>> +++ b/Documentation/config/advice.adoc\n>> @@ -90,6 +90,10 @@ all advice messages.\n>>  \t\tShown when linkgit:git-push[1] rejects a forced update of\n>>  \t\ta branch when its remote-tracking ref has updates that we\n>>  \t\tdo not have locally.\n>> +\tpushRefUnverifiable::\n>> +\t\tShown when linkgit:git-push[1] rejects a forced update of\n>> +\t\ta branch when we are unable to verify the remote-tracking\n>> +\t\tref is available locally.\n>\n> We don't really care about the ref being available, but rather about it\n> being integrated, right? So maybe s/available/integrated/.\n\nAh, I missed that one.  \"available locally\" is not of interest.  We\ncannot tell if we integrated it is what matters.\n\nThanks.\n"},{"id":"552613","messageId":"CAHLx=OkhLqR8eQaW9q9W4SgpdtGoCrX4T7CwoGoxaT-fiuEUxA@mail.gmail.com","threadId":"66273","inReplyTo":"aqOlx5dlprfc0bdO@pks.im","subject":"Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-11T22:58:55Z","receivedAt":"2026-09-11T22:59:08Z","isPatch":true,"body":"On Fri, Sep 11, 2026 at 12:55 AM Patrick Steinhardt <ps@pks.im> wrote:\n>\n> On Thu, Sep 10, 2026 at 05:05:05PM -0600, Tyler Cipriani wrote:\n> > \"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\n> > reachable from one of the 'reflog' entries of the local branch.\"\n> >\n> > But check_if_includes_upstream() uses the local per-branch reflog based\n> > on the destination branch rather than the branch being pushed; using\n> > ref->name vs. ref->peer_ref->name.\n>\n> So... in a `git push origin foo:bar` we look up the reflog for \"bar\" and\n> not \"foo\"?\n\nExactly.\n\n> > This can cause confusing rejections or unintended data loss.\n> >\n> > Using a command like:\n> >\n> >     git push --force-if-includes --force-with-lease origin src:main\n> >\n> > False rejections: when src is an up-to-date branch, but main is\n> > out-of-date or nonexistent, then the includes check will fail telling\n> > users the remote ref has been updated since the last checkout.\n>\n> Hm. \"up-to-date branch\" in relation to what? You mean if we had commits\n> A, B and C, with C being the most recent commit, then \"src\" points to C\n> and \"main\" points to B?\n\nYou got it. It should read something like: \"False rejections: when src\nis up-to-date with the tip of the remote ref, but...\" etc.\n\nI can clarify in a v4.\n\n> > Data loss: when src is an orphan/out-dated branch, but main is\n> > up-to-date, then the if-includes check will allow the push, clobbering\n> > the remote main.\n>\n> Right, here \"src\" would point to B and \"main\" would point to C.\n>\n> > Find local reflog using ref->peer_ref. When using a refspec like\n> > HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n> > branch's reflog.\n> >\n> > But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\n> > reject the push. HEAD's reflog is too broad to tell us if the history\n> > being pushed includes the tip of the remote. Rejecting a detached HEAD\n> > already happens today (if the same-named local branch lacks the remote\n> > tip); now the detached HEAD state is explicitly rejected.\n>\n> Makes sense.\n>\n> > Skip deletions:\n> >\n> >     git push --force-if-includes --force-with-lease origin :main\n> >\n> > ref->deletion is set after apply_push_cas (which triggers\n> > check_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\n> > Instead check with is_null_oid to detect and allow deletion.\n>\n> This part feels a bit off to me. Deletions are the most risky operation\n> that we can do, so why would we want to just blindly allow them? There\n> may be good reasons for this, but if so those should be documented as\n> part of the commit message. It would probably even be sufficient to say\n> \"it has worked this way before, and we don't want to break that case\".\n\nFor deletions, there's no history on our side to check. Also, there\nwas an existing test case that ensured deletions were allowed. I took\nthat as intent and opted to keep that behavior. I'll clarify in the\ncommit.\n\n> > diff --git a/remote.c b/remote.c\n> > index 00723b385e..326af76eeb 100644\n> > --- a/remote.c\n> > +++ b/remote.c\n> > @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n> >   */\n> >  static void check_if_includes_upstream(struct ref *remote)\n> >  {\n> > -     struct ref *local = get_local_ref(remote->name);\n> > +     struct ref *local;\n> > +     const char *name;\n> > +     int flag;\n> > +\n> > +     if (!remote->peer_ref)\n> > +             return;\n> > +\n> > +     /* A deletion has no local history to check against. */\n> > +     if (is_null_oid(&remote->peer_ref->new_oid))\n> > +             return;\n> > +\n> > +     name = remote->peer_ref->name;\n> > +     if (!strcmp(name, \"HEAD\")) {\n> > +             name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n> > +                                            \"HEAD\", 0, NULL, &flag);\n>\n> Shouldn't we pass `RESOLVE_REF_READING | RESOLVE_REF_NO_RECURSE` here?\n> Otherwise, the function will return \"HEAD\" even if it could not be\n> resolved, and we don't want to recursively resolve symrefs, either.\n\nRESOLVE_REF_READING: agreed. Will add.\nRESOLVE_REF_NO_RECURSE: For the current (v3) state that only looks at\n\"HEAD\" that makes sense. But I'd expect --force-if-includes to\nresolve, e.g., STABLE -> HEAD -> refs/heads/main -- that is, to\nrecurse through multiple symlinks. Otherwise, we'd reject a push we\ncould verify.\n\n> Also, is it sufficient to single out \"HEAD\" here? It could for example\n> be that the user passes \"HEAD~\", an object ID or really any other\n> revision, and these should probably not be considered reachable, either,\n> right?\n\nOooh, great catch! Folks could put in tags or specific oids, none of\nwhich have a reflog to check. These are rejected in v3, but only by\nhappenstance since they lack a reflog (and with bad advice about\nrunning \"git pull\").\n\n> Maybe we should instead verify whether this names a local reference and,\n> if so, resolve potential symrefs to their target.\n\nYes, that makes sense. Resolve symrefs to the target branch, then\ncheck the branch's reflog.\n\nI'll try that in v4.\n\nThis comment left me spiraling for a bit about tags. Like: you can\npush tags and a tag and a branch might point to the same commit. BUT\ntags don't have reflogs, so I think it makes sense to reject those as\nunverifiable here, too.\n\n> > diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\n> > index cba26a872d..0c02151747 100755\n> > --- a/t/t5533-push-cas.sh\n> > +++ b/t/t5533-push-cas.sh\n> > @@ -396,4 +396,69 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n> >       )\n> >  '\n> >\n> > +test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n> > +     setup_src_dup_dst &&\n> > +     test_when_finished \"rm -fr dst src dup\" &&\n> > +     (\n> > +             cd src &&\n> > +             git fetch &&\n> > +             git switch -c newbranch origin/main &&\n> > +             git rebase HEAD --onto HEAD^ &&\n> > +             git push --force-if-includes --force-with-lease origin newbranch:main\n> > +     )\n> > +'\n>\n> Nit: missing empty line between these two tests.\n\nAck.\n\n> Patrick\n\nThanks for the review!\n"},{"id":"552615","messageId":"CAHLx=Om0-2J2ibJT+VeX3eEYsmsjY20QQcV_sns==7qOKLN7DA@mail.gmail.com","threadId":"66273","inReplyTo":"xmqq4ifverdh.fsf@gitster.g","subject":"Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-11T23:47:15Z","receivedAt":"2026-09-11T23:47:27Z","isPatch":true,"body":"On Fri, Sep 11, 2026 at 9:31 AM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Tyler Cipriani <tyler@tylercipriani.com> writes:\n>\n> >  static void check_if_includes_upstream(struct ref *remote)\n> >  {\n> > -     struct ref *local = get_local_ref(remote->name);\n> > +     struct ref *local;\n> > +     const char *name;\n> > +     int flag;\n> > +\n> > +     if (!remote->peer_ref)\n> > +             return;\n>\n> This function signals its displeasure by setting remote->unreachble\n> to true, so any early return means it is OK to force the push, right?\n\nThat's true, for each ref that will be pushed. But this return does\nnot imply it's OK to force push; refs with no peer_ref are not part of\nthe push. The caller (apply_push_cas) walks every ref in remote_refs,\nthen this function gets called for each ref that has check_reachable,\nregardless of whether it will later be pushed.\n\nWe could move this check to apply_push_cas to winnow what\ncheck_if_includes_upstream is responsible for checking and make every\nbare return mean \"OK to force\"; i.e., change apply_push_cas from:\n\nif (ref->check_reachable)\n    check_if_includes_upstream(ref);\n\nto:\n\nif (ref->peer_ref && ref->check_reachable)\n    check_if_includes_upstream(ref);\n\nAnd drop this return (and probably add a comment). I like that better.\n\n> What is the significance of remote not having peer_ref?  Is it a\n> usage error (i.e., push is not updating anything over there, and it\n> makes me wonder what the command line to do so looks like)?  Is it a\n> programming error (i.e., if we are pushing to update no remote ref,\n> this function should never be called)?  If the latter, I wonder if\n> BUG() is more appropriate.\n\nThis is an ordinary path vs. BUG(). For the command:\n\ngit --force-with-lease --force-if-includes origin main\n\napply_push_cas checks all advertised refs. If there's no peer_ref,\nthen remote.c's set_ref_status_for_push skips the ref before even\nchecking ref->unreachable. When I ran the coverage report, this guard\nwas hit regularly.\n\n> > +     /* A deletion has no local history to check against. */\n> > +     if (is_null_oid(&remote->peer_ref->new_oid))\n> > +             return;\n>\n> The comment for this condition is clear.  If we are pushing to\n> delete, checking if our side once used to build on top of theirs\n> does not guarantee us anything, so we accept the loss of history.\n\nAgreed.\n\n> > +     name = remote->peer_ref->name;\n> > +     if (!strcmp(name, \"HEAD\")) {\n> > +             name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n> > +                                            \"HEAD\", 0, NULL, &flag);\n> > +             if (!name || !(flag & REF_ISSYMREF)) {\n> > +                     /* detached HEAD: no per-branch reflog to consult */\n> > +                     remote->unreachable = 1;\n> > +                     return;\n> > +             }\n> > +     }\n> > +\n> > +     local = get_local_ref(name);\n> >       if (!local)\n> >               return;\n>\n> The same question here.\n\nget_local_ref should not return null. And when I ran the coverage\nreport, this guard never ran. I'd be happy to remove it in v4.\n\n> Are any of these silent \"punt\" returns tested below?  It does not\n> seem to add a new test about pushing-to-delete.\n\nThere is an existing test for push-to-delete that this patch set kept.\n\n> Thanks.\n\nThanks for the review!\n\nPatrick suggested generalizing away from checking \"HEAD\" and I think\nthat's the right call. I'll try that, plus adding your feedback (plus\nsome additional detail in comments) in a v4.\n"},{"id":"552657","messageId":"20260914040018.76111-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v4 0/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-14T04:00:16Z","receivedAt":"2026-09-14T04:00:24Z","isPatch":true,"body":"Changes since v3:\n\n- check_if_includes_upstream unconditionally resolves peer_ref with\n  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n  when using --force-if-includes\n- add test for --force-if-includes tag push 1/2\n- clarify log message problem example 1/2\n- clarify deletion in log message 1/2\n- add missing blank line between test cases\n- shorten long line in builtin/push.c\n- reword advice-message wording 2/2\n- rename 2/2 from \"detached HEAD\" to \"non-branch\"\n\nChanges since v2:\n\n- Correct patch threading of 1/2 and 2/2 to reply to cover letter of\n  current patchset vs. cover letter of the initial iteration.\n\nChanges since v1:\n\n- Clarify in log message 1/2 that --force-if-includes will reject a\n  detached HEAD today (when the same-named local branch lacks the remote\n  tip). And note that this change makes it explicit to always reject\n  the detached HEAD case.\n\n--force-if-includes has been checking the reflog of the local branch named\nafter the destination branch regardless of what's being pushed. This can cause\nfalse rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n               false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local branch\ncontains the remote tip but you --force-if-includes push an unrelated branch,\nclobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\nfail against maint, but pass with patches applied.\n\nExisting tests covered refspecs with different names for --force-with-lease,\nbut missed --force-if-includes. New patches cover:\n\n- allow forced-update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch lacking\n  branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n\nResolved question: the detached HEAD case; HEAD's reflog was considered\nand rejected as too broad for purpose in the original review. cf. [2]\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n[2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>\n\nTyler Cipriani (2):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes non-branch advice\n\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 17 ++++++\n builtin/send-pack.c              |  5 ++\n remote.c                         | 29 ++++++++++-\n remote.h                         | 10 ++--\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 88 +++++++++++++++++++++++++++++++-\n transport-helper.c               |  5 ++\n transport.c                      |  8 +++\n transport.h                      |  1 +\n 12 files changed, 164 insertions(+), 6 deletions(-)\n\nRange-diff against v3:\n1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n    @@ Commit message\n         on the destination branch rather than the branch being pushed; using\n         ref->name vs. ref->peer_ref->name.\n     \n    -    This can cause confusing rejections or unintended data loss.\n    -\n    -    Using a command like:\n    +    For example, this command looks at the reflog for main vs. src, even\n    +    though src is being pushed:\n     \n             git push --force-if-includes --force-with-lease origin src:main\n     \n    -    False rejections: when src is an up-to-date branch, but main is\n    -    out-of-date or nonexistent, then the includes check will fail telling\n    -    users the remote ref has been updated since the last checkout.\n    +    This can cause confusing rejections or unintended data loss.\n    +\n    +    False rejections: when src is up-to-date with the tip of origin's main,\n    +    but main is out-of-date or nonexistent, then the force-if-includes check\n    +    will fail, telling users the remote ref has been updated since the last\n    +    checkout.\n     \n         Data loss: when src is an orphan/out-dated branch, but main is\n    -    up-to-date, then the if-includes check will allow the push, clobbering\n    -    the remote main.\n    +    up-to-date, then the force-if-includes check will allow the push,\n    +    clobbering the remote main.\n     \n    -    Find local reflog using ref->peer_ref. When using a refspec like\n    -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n    -    branch's reflog.\n    +    Instead, use ref->peer_ref to locate a branch with a reflog. But if ref\n    +    does not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\n    +    then we reject the push. The alternative would be to use HEAD's reflog,\n    +    which is too broad to tell us if the history being pushed includes the\n    +    tip of the remote. We need a per-branch reflog, which means that pushes\n    +    of a ref that do not resolve to a branch are rejected. Rejecting the\n    +    push of a ref like a detached HEAD already happens today (if the\n    +    same-named local branch lacks the remote tip); now the detached HEAD and\n    +    other non-branch pushes are explicitly rejected.\n     \n    -    But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we\n    -    reject the push. HEAD's reflog is too broad to tell us if the history\n    -    being pushed includes the tip of the remote. Rejecting a detached HEAD\n    -    already happens today (if the same-named local branch lacks the remote\n    -    tip); now the detached HEAD state is explicitly rejected.\n    -\n    -    Skip deletions:\n    +    Allow deletions, e.g.:\n     \n             git push --force-if-includes --force-with-lease origin :main\n     \n    +    A deletion has no source ref, so no branch reflog can be checked.\n    +    Existing tests already enforce that deletions should work with\n    +    force-if-includes.\n    +\n         ref->deletion is set after apply_push_cas (which triggers\n         check_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\n         Instead check with is_null_oid to detect and allow deletion.\n     \n    +    The early return when peer_ref is missing in check_if_includes_upstream\n    +    is necessary because apply_push_cas walks every advertised ref whenever\n    +    use_tracking_for_rest is set (i.e., a bare --force-with-lease), so\n    +    check_if_includes upstream is called for for refs that are not part of\n    +    the push.\n    +\n    +    Remove unnecessary check for empty return from get_local_ref, since it\n    +    never returns NULL for a non-empty name.\n    +\n         Reported-by: Stefan Haller <lists@haller-berlin.de>\n         Reported-by: D. Ben Knoble <ben.knoble@gmail.com>\n         Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n    @@ remote.c: static int is_reachable_in_reflog(const char *local, const struct ref\n      static void check_if_includes_upstream(struct ref *remote)\n      {\n     -\tstruct ref *local = get_local_ref(remote->name);\n    +-\tif (!local)\n     +\tstruct ref *local;\n     +\tconst char *name;\n    -+\tint flag;\n     +\n    ++\t/* ref without peer_ref will not be pushed */\n     +\tif (!remote->peer_ref)\n    -+\t\treturn;\n    -+\n    + \t\treturn;\n    + \n     +\t/* A deletion has no local history to check against. */\n     +\tif (is_null_oid(&remote->peer_ref->new_oid))\n     +\t\treturn;\n     +\n    -+\tname = remote->peer_ref->name;\n    -+\tif (!strcmp(name, \"HEAD\")) {\n    -+\t\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n    -+\t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n    -+\t\tif (!name || !(flag & REF_ISSYMREF)) {\n    -+\t\t\t/* detached HEAD: no per-branch reflog to consult */\n    -+\t\t\tremote->unreachable = 1;\n    -+\t\t\treturn;\n    -+\t\t}\n    ++\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n    ++\t\t\t\t       remote->peer_ref->name,\n    ++\t\t\t\t       RESOLVE_REF_READING, NULL, NULL);\n    ++\n    ++\t/*\n    ++\t * if we resolve the ref to anything other than a branch,\n    ++\t * then there is no reliable reflog to check\n    ++\t */\n    ++\tif (!name || !starts_with(name, \"refs/heads/\")) {\n    ++\t\tremote->unreachable = 1;\n    ++\t\treturn;\n     +\t}\n     +\n     +\tlocal = get_local_ref(name);\n    - \tif (!local)\n    - \t\treturn;\n    - \n    ++\n    + \tif (is_reachable_in_reflog(local->name, remote) <= 0)\n    + \t\tremote->unreachable = 1;\n    + \tfree_one_ref(local);\n     \n      ## t/t5533-push-cas.sh ##\n     @@ t/t5533-push-cas.sh: test_expect_success '\"--force-if-includes\" should allow deletes' '\n    @@ t/t5533-push-cas.sh: test_expect_success '\"--force-if-includes\" should allow del\n     +\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n     +\t)\n     +'\n    ++\n     +test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n     +\tsetup_src_dup_dst &&\n     +\ttest_when_finished \"rm -fr dst src dup\" &&\n    @@ t/t5533-push-cas.sh: test_expect_success '\"--force-if-includes\" should allow del\n     +\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n     +\t)\n     +'\n    ++\n    ++test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n    ++\tsetup_src_dup_dst &&\n    ++\ttest_when_finished \"rm -fr dst src dup\" &&\n    ++\t(\n    ++\t\tcd src &&\n    ++\t\tgit fetch &&\n    ++\t\tgit switch main &&\n    ++\t\tgit reset --hard origin/main &&\n    ++\t\tgit switch -c newbranch origin/main &&\n    ++\t\tgit checkout HEAD^ &&\n    ++\t\tgit tag stable &&\n    ++\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n    ++\t)\n    ++'\n     +\n      test_done\n2:  e07d16d53e ! 2:  2a455d8a76 push: fix --force-if-includes detached HEAD advice\n    @@ Metadata\n     Author: Tyler Cipriani <tyler@tylercipriani.com>\n     \n      ## Commit message ##\n    -    push: fix --force-if-includes detached HEAD advice\n    +    push: fix --force-if-includes non-branch advice\n     \n    -    When a --force-if-includes push is rejected due to a detached HEAD\n    -    state where there is no per-branch reflog to consult, the advice is\n    -    misleading:\n    +    When a --force-if-includes push is rejected due lacking reflog to\n    +    consult, the advice is misleading:\n     \n              ! [rejected] HEAD -> main (remote ref updated since checkout)\n             error: failed to push some refs to '<remote>'\n    @@ Commit message\n         - Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n         - Ignore the error with --no-force-if-includes\n     \n    -    Add ref->unverifiable to differentiate between a detached HEAD rejection\n    -    vs. a remote update rejection.\n    +    Add ref->unverifiable to differentiate pushing something without a\n    +    reflog to consult vs. a remote update rejection.\n     \n         Ensure tests check the rejection message.\n     \n    @@ Documentation/config/advice.adoc: all advice messages.\n     +\tpushRefUnverifiable::\n     +\t\tShown when linkgit:git-push[1] rejects a forced update of\n     +\t\ta branch when we are unable to verify the remote-tracking\n    -+\t\tref is available locally.\n    ++\t\tref is integrated locally.\n      \tpushUnqualifiedRefname::\n      \t\tShown when linkgit:git-push[1] gives up trying to\n      \t\tguess based on the source and destination refs what\n    @@ builtin/push.c: static const char message_advice_ref_needs_update[] =\n      \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n      \n     +static const char message_advice_ref_unverifiable[] =\n    -+\tN_(\"Updates were rejected because the tip of the remote-tracking branch\\n\"\n    -+\t   \"cannot be checked against a detached HEAD. If you want to push anyway,\\n\"\n    -+\t   \"specify the expected value with '--force-with-lease=<ref>:<expect>'\\n\"\n    -+\t   \"or use '--no-force-if-includes' to skip this check.\");\n    ++\tN_(\"Updates were rejected because what you are pushing is not a branch,\\n\"\n    ++\t   \"so there is no reflog to check against the tip of the remote-tracking\\n\"\n    ++\t   \"branch. If you want to push anyway, specify the expected value with\\n\"\n    ++\t   \"'--force-with-lease=<ref>:<expect>' or use '--no-force-if-includes'\\n\"\n    ++\t   \"to skip this check.\");\n     +\n      static void advise_pull_before_push(void)\n      {\n    @@ builtin/push.c: static void advise_ref_needs_update(void)\n      \n     +static void advise_ref_unverifiable(void)\n     +{\n    -+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n    ++\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) ||\n    ++\t\t\t!advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n     +\t\treturn;\n     +\tadvise(_(message_advice_ref_unverifiable));\n     +}\n    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n      \t\t\t\t/*\n      \t\t\t\t * If the ref isn't stale, and is reachable\n     @@ remote.c: static void check_if_includes_upstream(struct ref *remote)\n    - \t\t\t\t\t       \"HEAD\", 0, NULL, &flag);\n    - \t\tif (!name || !(flag & REF_ISSYMREF)) {\n    - \t\t\t/* detached HEAD: no per-branch reflog to consult */\n    --\t\t\tremote->unreachable = 1;\n    -+\t\t\tremote->unverifiable = 1;\n    - \t\t\treturn;\n    - \t\t}\n    + \t * then there is no reliable reflog to check\n    + \t */\n    + \tif (!name || !starts_with(name, \"refs/heads/\")) {\n    +-\t\tremote->unreachable = 1;\n    ++\t\tremote->unverifiable = 1;\n    + \t\treturn;\n      \t}\n    + \n     \n      ## remote.h ##\n     @@ remote.h: struct ref {\n    @@ t/t5533-push-cas.sh: test_expect_success '\"--force-if-includes\" should reject fo\n      \t)\n      '\n      \n    +@@ t/t5533-push-cas.sh: test_expect_success '\"--force-if-includes\" should reject forced update from tag'\n    + \t\tgit switch -c newbranch origin/main &&\n    + \t\tgit checkout HEAD^ &&\n    + \t\tgit tag stable &&\n    +-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n    ++\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main 2>err &&\n    ++\t\ttest_grep \"remote ref unverifiable\" err &&\n    ++\t\ttest_grep \"no-force-if-includes\" err\n    + \t)\n    + '\n    + \n     \n      ## transport-helper.c ##\n     @@ transport-helper.c: static int push_update_ref_status(struct strbuf *buf,\n-- \n2.47.3\n\n"},{"id":"552658","messageId":"20260914040018.76111-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260914040018.76111-1-tyler@tylercipriani.com","subject":"[PATCH v4 1/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-14T04:00:17Z","receivedAt":"2026-09-14T04:00:43Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nFor example, this command looks at the reflog for main vs. src, even\nthough src is being pushed:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nThis can cause confusing rejections or unintended data loss.\n\nFalse rejections: when src is up-to-date with the tip of origin's main,\nbut main is out-of-date or nonexistent, then the force-if-includes check\nwill fail, telling users the remote ref has been updated since the last\ncheckout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the force-if-includes check will allow the push,\nclobbering the remote main.\n\nInstead, use ref->peer_ref to locate a branch with a reflog. But if ref\ndoes not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\nthen we reject the push. The alternative would be to use HEAD's reflog,\nwhich is too broad to tell us if the history being pushed includes the\ntip of the remote. We need a per-branch reflog, which means that pushes\nof a ref that do not resolve to a branch are rejected. Rejecting the\npush of a ref like a detached HEAD already happens today (if the\nsame-named local branch lacks the remote tip); now the detached HEAD and\nother non-branch pushes are explicitly rejected.\n\nAllow deletions, e.g.:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nA deletion has no source ref, so no branch reflog can be checked.\nExisting tests already enforce that deletions should work with\nforce-if-includes.\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nThe early return when peer_ref is missing in check_if_includes_upstream\nis necessary because apply_push_cas walks every advertised ref whenever\nuse_tracking_for_rest is set (i.e., a bare --force-with-lease), so\ncheck_if_includes upstream is called for for refs that are not part of\nthe push.\n\nRemove unnecessary check for empty return from get_local_ref, since it\nnever returns NULL for a non-empty name.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 26 +++++++++++++--\n t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 105 insertions(+), 2 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..887c7ec00c 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n-\tif (!local)\n+\tstruct ref *local;\n+\tconst char *name;\n+\n+\t/* ref without peer_ref will not be pushed */\n+\tif (!remote->peer_ref)\n \t\treturn;\n \n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t       remote->peer_ref->name,\n+\t\t\t\t       RESOLVE_REF_READING, NULL, NULL);\n+\n+\t/*\n+\t * if we resolve the ref to anything other than a branch,\n+\t * then there is no reliable reflog to check\n+\t */\n+\tif (!name || !starts_with(name, \"refs/heads/\")) {\n+\t\tremote->unreachable = 1;\n+\t\treturn;\n+\t}\n+\n+\tlocal = get_local_ref(name);\n+\n \tif (is_reachable_in_reflog(local->name, remote) <= 0)\n \t\tremote->unreachable = 1;\n \tfree_one_ref(local);\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..265be6a84c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,85 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\tgit tag stable &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552659","messageId":"20260914040018.76111-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260914040018.76111-1-tyler@tylercipriani.com","subject":"[PATCH v4 2/2] push: fix --force-if-includes non-branch advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-14T04:00:18Z","receivedAt":"2026-09-14T04:00:48Z","isPatch":true,"body":"When a --force-if-includes push is rejected due lacking reflog to\nconsult, the advice is misleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate pushing something without a\nreflog to consult vs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 17 +++++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 11 ++++++++---\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 62 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..8d258980ff 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is integrated locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..679d9cee83 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,13 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because what you are pushing is not a branch,\\n\"\n+\t   \"so there is no reflog to check against the tip of the remote-tracking\\n\"\n+\t   \"branch. If you want to push anyway, specify the expected value with\\n\"\n+\t   \"'--force-with-lease=<ref>:<expect>' or use '--no-force-if-includes'\\n\"\n+\t   \"to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +368,14 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) ||\n+\t\t\t!advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +427,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 887c7ec00c..b7b5ac0d28 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2826,7 +2829,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t * then there is no reliable reflog to check\n \t */\n \tif (!name || !starts_with(name, \"refs/heads/\")) {\n-\t\tremote->unreachable = 1;\n+\t\tremote->unverifiable = 1;\n \t\treturn;\n \t}\n \ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 265be6a84c..38576917e4 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -458,7 +459,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \n@@ -473,7 +476,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from tag'\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n \t\tgit tag stable &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552704","messageId":"CALnO6CDz8QBcBojmhjgwgWzi4oUbs+V4KVQ1h0+JgN7k0v-SYQ@mail.gmail.com","threadId":"66273","inReplyTo":"20260914040018.76111-1-tyler@tylercipriani.com","subject":"Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-14T13:03:46Z","receivedAt":"2026-09-14T13:03:58Z","isPatch":true,"body":"Hi Tyler,\n\nOn Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n>\n> Changes since v3:\n>\n> - check_if_includes_upstream unconditionally resolves peer_ref with\n>   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n>   when using --force-if-includes\n> - add test for --force-if-includes tag push 1/2\n\nThis is intriguing and seems like a significant behavior change, let's read on…\n\n> Range-diff against v3:\n> 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n>     @@ Commit message\n>     -    Find local reflog using ref->peer_ref. When using a refspec like\n>     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n>     -    branch's reflog.\n>     +    Instead, use ref->peer_ref to locate a branch with a reflog. But if ref\n>     +    does not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\n>     +    then we reject the push. The alternative would be to use HEAD's reflog,\n>     +    which is too broad to tell us if the history being pushed includes the\n>     +    tip of the remote. We need a per-branch reflog, which means that pushes\n>     +    of a ref that do not resolve to a branch are rejected. Rejecting the\n>     +    push of a ref like a detached HEAD already happens today (if the\n>     +    same-named local branch lacks the remote tip); now the detached HEAD and\n>     +    other non-branch pushes are explicitly rejected.\n\nSo, we would now reject a force-push whose source is anything but a branch (with\nforce-if-includes, and that presumably includes push.useForceIfIncludes)?\n\n>     ++test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n>     ++  setup_src_dup_dst &&\n>     ++  test_when_finished \"rm -fr dst src dup\" &&\n>     ++  (\n>     ++          cd src &&\n>     ++          git fetch &&\n>     ++          git switch main &&\n>     ++          git reset --hard origin/main &&\n>     ++          git switch -c newbranch origin/main &&\n>     ++          git checkout HEAD^ &&\n>     ++          git tag stable &&\n>     ++          test_must_fail git push --force-if-includes --force-with-lease origin stable:main\n>     ++  )\n>     ++'\n\nWhich is what I think this test says.\n\nI think this would break a common thing I do at work (although this is soon to\nbe deprecated, so take my anecdote with appropriate salt; I can't claim that no\none else relies on it, of course):\n\nAs I think I described in the message you linked, I have an alias \"pf = push\n--force-with-lease\" and push.useForceIfIncludes=true in config. Our team has a\n\"main\" release branch and a \"hotfix\" release branch for emergencies. When\nhotfixing, we first reset the hotfix branch to the last tag to go out to our\nproduction environment, which I typically do like this:\n\n    # validate that we won't lose any interesting commits (no regressions) with\n    # something like\n    git log --oneline --graph --boundary --cherry-mark --left-right\norigin/hotfix...<TAG>\n    # push\n    git pf origin <TAG>:hotfix\n\n(On a second pass before sending, I can't recall if this works as-is when I\ndon't have a local hotfix branch tracking origin/hotfix.)\n\nIf I'm reading this version right, I would now have to say\n\n    git pf --no-force-if-includes origin <TAG>:hotfix\n\nor perhaps better\n\n    git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …\n\nprobably after seeing a (hopefully improved?) message after the original\ncommand. (Do I need to disable force-if-includes in the more-specific lease\ncommand?)\n\nNow, on the one hand, enshrining existing behavior is good for backwards\ncompatibility but has earned us a bit of a reputation for not innovating in\nuseful ways ;) On the other, I wonder if the description of force-if-includes\nallows some latitude to break with existing behavior here.\n\nThe relevant docs say\n\n       --force-if-includes, --no-force-if-includes\n           Force an update only if the tip of the remote-tracking ref has been\n           integrated locally.\n\n           This option enables a check that verifies if the tip of the\n           remote-tracking ref is reachable from one of the \"reflog\" entries of\n           the local branch based in it for a rewrite. The check ensures that\n           any updates from the remote have been incorporated locally by\n           rejecting the forced update if that is not the case.\n\nIt is unclear to me what \"one of the 'reflog' entries of the local branch based\nin it\" means! Ignoring that, the surrounding text only talks about whether the\nremote-tracking ref's tip (or \"updates from the remote\") have been \"integrated\nlocally.\"\n\nSo I think we *could* say that, in this case, we don't have enough information\nfrom \"<TAG>:hotfix\" to check whether \"origin/hotfix\" has been integrated locally\nor not, and we should tighten the meaning of the check. (Perhaps when\n\"--force-with-lease=hotfix\" is given, though, we now have more information\navailable to check---but that could be outside the scope of this series if we\ndon't mind breaking backwards compatibility now.)\n\nThanks,\nD. Ben Knoble\n"},{"id":"552724","messageId":"CAHLx=O=eH=7g=JUn5dOJgatv2xJVbQygK4C7zCA0Uv=BpzMkxQ@mail.gmail.com","threadId":"66273","inReplyTo":"CALnO6CDz8QBcBojmhjgwgWzi4oUbs+V4KVQ1h0+JgN7k0v-SYQ@mail.gmail.com","subject":"Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-14T19:27:19Z","receivedAt":"2026-09-14T19:27:37Z","isPatch":true,"body":"On Mon, Sep 14, 2026 at 7:03 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:\n> Hi Tyler,\n\nHi Ben!\n\n> On Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n> >\n> > Changes since v3:\n> >\n> > - check_if_includes_upstream unconditionally resolves peer_ref with\n> >   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n> >   when using --force-if-includes\n> > - add test for --force-if-includes tag push 1/2\n>\n> This is intriguing and seems like a significant behavior change, let's read on…\n\nIt's definitely true that this is a behavior change and it'll add some\nfriction to your process. And it's also true that the current behavior\nis failing to provide the guarantees it claims.\n\n> > Range-diff against v3:\n> > 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n> >     @@ Commit message\n> >     -    Find local reflog using ref->peer_ref. When using a refspec like\n> >     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n> >     -    branch's reflog.\n> >     +    Instead, use ref->peer_ref to locate a branch with a reflog. But if ref\n> >     +    does not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\n> >     +    then we reject the push. The alternative would be to use HEAD's reflog,\n> >     +    which is too broad to tell us if the history being pushed includes the\n> >     +    tip of the remote. We need a per-branch reflog, which means that pushes\n> >     +    of a ref that do not resolve to a branch are rejected. Rejecting the\n> >     +    push of a ref like a detached HEAD already happens today (if the\n> >     +    same-named local branch lacks the remote tip); now the detached HEAD and\n> >     +    other non-branch pushes are explicitly rejected.\n>\n> So, we would now reject a force-push whose source is anything but a branch (with\n> force-if-includes, and that presumably includes push.useForceIfIncludes)?\n\nI should clarify, reject the force-push of any source not ultimately\nresolvable to a branch; e.g., HEAD will work if resolves to a branch.\n\n> >     ++test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n> >     ++  setup_src_dup_dst &&\n> >     ++  test_when_finished \"rm -fr dst src dup\" &&\n> >     ++  (\n> >     ++          cd src &&\n> >     ++          git fetch &&\n> >     ++          git switch main &&\n> >     ++          git reset --hard origin/main &&\n> >     ++          git switch -c newbranch origin/main &&\n> >     ++          git checkout HEAD^ &&\n> >     ++          git tag stable &&\n> >     ++          test_must_fail git push --force-if-includes --force-with-lease origin stable:main\n> >     ++  )\n> >     ++'\n>\n> Which is what I think this test says.\n>\n> I think this would break a common thing I do at work (although this is soon to\n> be deprecated, so take my anecdote with appropriate salt; I can't claim that no\n> one else relies on it, of course):\n>\n> As I think I described in the message you linked, I have an alias \"pf = push\n> --force-with-lease\" and push.useForceIfIncludes=true in config. Our team has a\n> \"main\" release branch and a \"hotfix\" release branch for emergencies. When\n> hotfixing, we first reset the hotfix branch to the last tag to go out to our\n> production environment, which I typically do like this:\n>\n>     # validate that we won't lose any interesting commits (no regressions) with\n>     # something like\n>     git log --oneline --graph --boundary --cherry-mark --left-right\n> origin/hotfix...<TAG>\n>     # push\n>     git pf origin <TAG>:hotfix\n>\n> (On a second pass before sending, I can't recall if this works as-is when I\n> don't have a local hotfix branch tracking origin/hotfix.)\n\nYes, this workflow will break. And it will not work today without a\nlocal branch named \"hotfix\". It's broken today, insofar as this is a\nfalse pass since push.useForceIfIncludes is unable to say anything\nabout whether you've integrated origin's hotfix branch into the <TAG>,\nyou're pushing so it only incidentally works.\n\nToday, git pf is actually checking that your refs/heads/hotfix's\nreflog has the tip of origin's refs/heads/hotfix. But it makes no\npromises about <TAG>. That is, you could:\n\n    git checkout hotfix && git pull # This line is what makes it work today\n    git checkout --orphan junk\n    git commit -m --allow-empty 'Totally unrelated empty commit'\n    git tag <TAG>\n    git pf origin <TAG>:hotfix\n\nAnd pf will allow that to happen since origin/hotfix's tip has been\nintegrated with your local refs/heads/hotfix, which is what it's\nchecking today.\n\n> If I'm reading this version right, I would now have to say\n>\n>     git pf --no-force-if-includes origin <TAG>:hotfix\n>\n> or perhaps better\n>\n>     git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …\n>\n> probably after seeing a (hopefully improved?) message after the original\n> command. (Do I need to disable force-if-includes in the more-specific lease\n> command?)\n\n    git pf --force-with-lease=hotfix:origin/hotfix origin <TAG>:hotfix\n\nShould be sufficient and as I understand your process, that's what\nyou're after. The explicit --force-with-lease argument makes\n--force-if-includes a no-op, so --no-force-if-includes should be\nunnecessary.\n\n> Now, on the one hand, enshrining existing behavior is good for backwards\n> compatibility but has earned us a bit of a reputation for not innovating in\n> useful ways ;) On the other, I wonder if the description of force-if-includes\n> allows some latitude to break with existing behavior here.\n>\n> The relevant docs say\n>\n>        --force-if-includes, --no-force-if-includes\n>            Force an update only if the tip of the remote-tracking ref has been\n>            integrated locally.\n>\n>            This option enables a check that verifies if the tip of the\n>            remote-tracking ref is reachable from one of the \"reflog\" entries of\n>            the local branch based in it for a rewrite. The check ensures that\n>            any updates from the remote have been incorporated locally by\n>            rejecting the forced update if that is not the case.\n>\n> It is unclear to me what \"one of the 'reflog' entries of the local branch based\n> in it\" means! Ignoring that, the surrounding text only talks about whether the\n> remote-tracking ref's tip (or \"updates from the remote\") have been \"integrated\n> locally.\"\n>\n> So I think we *could* say that, in this case, we don't have enough information\n> from \"<TAG>:hotfix\" to check whether \"origin/hotfix\" has been integrated locally\n> or not, and we should tighten the meaning of the check. (Perhaps when\n> \"--force-with-lease=hotfix\" is given, though, we now have more information\n> available to check---but that could be outside the scope of this series if we\n> don't mind breaking backwards compatibility now.)\n\nFrom my perspective, this is similar to the detached HEAD discussion\nfrom 2020[0] where \"[the reflog of HEAD not attached to a branch]\n_does_ answer a different question from what we actually asked.\"\n\n[0]: <https://lore.kernel.org/git/nycvar.QRO.7.76.6.2009161214030.56@tvgsbejvaqbjf.bet/>\n\nI opted for a direction requiring explicit arguments to express\nintent, since that's the only way to ensure --force-if-includes aligns\nwith (how I read) the documentation and the previous discussions.\n\nSpecifically, with tags:\n\n- tags may have a reflog, but it answers a different question vs. \"has\nthis tag integrated changes from an upstream\" it answers what oid/ref\ndoes this tag point to\n- tags may incidentally point at oids referenced by branches with\nreflogs, but there may also be several branches pointed to the same\noid, so which would we choose?\n\nBUT I just realized there is existing, more fundamental breakage with\n--force-if-includes here that I'm making worse.\n\nThere is one case where we do have enough information to say whether\n<TAG> has integrated the tip of the remote-ref locally: fast-forward\npush. And that's actually broken today, too :)\n\n    git --version\n    git version 2.47.3\n    git clone repo.git repo && cd repo\n    git commit --allow-empty -m 'Normal, no-force-needed fast forward commit'\n    git reflog expire --expire=all --all\n    # Regular fast-forward push fails, even though it does not require\n--force to begin with\n    git push --force-with-lease --force-if-includes origin main\n    ! [rejected]        main -> main (remote ref updated since checkout)\n\nChecking for fast-forward happens after --force-if-includes checks the\nreflog. So that will need a fix…\n\nMy change makes an existing problem more acute, and probably requires\na fix before other fixes can merge. Otherwise, --force-if-includes\nwill always fail when pushing tags and detached heads, even when\nthey're fast forward changes, adding needless friction to otherwise\nsafe pushes (e.g., for tags that fast-forward a branch). So v5 will\nrequire a third change that touches other functions in remote.c. :/\n\n> Thanks,\n> D. Ben Knoble\n\nThank you for all the review and thoughts!\n"},{"id":"552729","messageId":"CALnO6CAaoNjGmU267j_OnMErxK=vjH-sy9hAMO-WvUFOk9_vMA@mail.gmail.com","threadId":"66273","inReplyTo":"CAHLx=O=eH=7g=JUn5dOJgatv2xJVbQygK4C7zCA0Uv=BpzMkxQ@mail.gmail.com","subject":"Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-14T20:52:04Z","receivedAt":"2026-09-14T20:52:19Z","isPatch":true,"body":"On Mon, Sep 14, 2026 at 3:27 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n>\n> On Mon, Sep 14, 2026 at 7:03 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:\n> > Hi Tyler,\n>\n> Hi Ben!\n>\n> > On Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n> > >\n> > > Changes since v3:\n> > >\n> > > - check_if_includes_upstream unconditionally resolves peer_ref with\n> > >   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n> > >   when using --force-if-includes\n> > > - add test for --force-if-includes tag push 1/2\n> >\n> > This is intriguing and seems like a significant behavior change, let's read on…\n>\n> It's definitely true that this is a behavior change and it'll add some\n> friction to your process. And it's also true that the current behavior\n> is failing to provide the guarantees it claims.\n>\n> > > Range-diff against v3:\n> > > 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n> > >     @@ Commit message\n> > >     -    Find local reflog using ref->peer_ref. When using a refspec like\n> > >     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that\n> > >     -    branch's reflog.\n> > >     +    Instead, use ref->peer_ref to locate a branch with a reflog. But if ref\n> > >     +    does not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\n> > >     +    then we reject the push. The alternative would be to use HEAD's reflog,\n> > >     +    which is too broad to tell us if the history being pushed includes the\n> > >     +    tip of the remote. We need a per-branch reflog, which means that pushes\n> > >     +    of a ref that do not resolve to a branch are rejected. Rejecting the\n> > >     +    push of a ref like a detached HEAD already happens today (if the\n> > >     +    same-named local branch lacks the remote tip); now the detached HEAD and\n> > >     +    other non-branch pushes are explicitly rejected.\n> >\n> > So, we would now reject a force-push whose source is anything but a branch (with\n> > force-if-includes, and that presumably includes push.useForceIfIncludes)?\n>\n> I should clarify, reject the force-push of any source not ultimately\n> resolvable to a branch; e.g., HEAD will work if resolves to a branch.\n>\n> > >     ++test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n> > >     ++  setup_src_dup_dst &&\n> > >     ++  test_when_finished \"rm -fr dst src dup\" &&\n> > >     ++  (\n> > >     ++          cd src &&\n> > >     ++          git fetch &&\n> > >     ++          git switch main &&\n> > >     ++          git reset --hard origin/main &&\n> > >     ++          git switch -c newbranch origin/main &&\n> > >     ++          git checkout HEAD^ &&\n> > >     ++          git tag stable &&\n> > >     ++          test_must_fail git push --force-if-includes --force-with-lease origin stable:main\n> > >     ++  )\n> > >     ++'\n> >\n> > Which is what I think this test says.\n> >\n> > I think this would break a common thing I do at work (although this is soon to\n> > be deprecated, so take my anecdote with appropriate salt; I can't claim that no\n> > one else relies on it, of course):\n> >\n> > As I think I described in the message you linked, I have an alias \"pf = push\n> > --force-with-lease\" and push.useForceIfIncludes=true in config. Our team has a\n> > \"main\" release branch and a \"hotfix\" release branch for emergencies. When\n> > hotfixing, we first reset the hotfix branch to the last tag to go out to our\n> > production environment, which I typically do like this:\n> >\n> >     # validate that we won't lose any interesting commits (no regressions) with\n> >     # something like\n> >     git log --oneline --graph --boundary --cherry-mark --left-right\n> > origin/hotfix...<TAG>\n> >     # push\n> >     git pf origin <TAG>:hotfix\n> >\n> > (On a second pass before sending, I can't recall if this works as-is when I\n> > don't have a local hotfix branch tracking origin/hotfix.)\n>\n> Yes, this workflow will break. And it will not work today without a\n> local branch named \"hotfix\". It's broken today, insofar as this is a\n> false pass since push.useForceIfIncludes is unable to say anything\n> about whether you've integrated origin's hotfix branch into the <TAG>,\n> you're pushing so it only incidentally works.\n>\n> Today, git pf is actually checking that your refs/heads/hotfix's\n> reflog has the tip of origin's refs/heads/hotfix. But it makes no\n> promises about <TAG>. That is, you could:\n>\n>     git checkout hotfix && git pull # This line is what makes it work today\n>     git checkout --orphan junk\n>     git commit -m --allow-empty 'Totally unrelated empty commit'\n>     git tag <TAG>\n>     git pf origin <TAG>:hotfix\n>\n> And pf will allow that to happen since origin/hotfix's tip has been\n> integrated with your local refs/heads/hotfix, which is what it's\n> checking today.\n>\n> > If I'm reading this version right, I would now have to say\n> >\n> >     git pf --no-force-if-includes origin <TAG>:hotfix\n> >\n> > or perhaps better\n> >\n> >     git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …\n> >\n> > probably after seeing a (hopefully improved?) message after the original\n> > command. (Do I need to disable force-if-includes in the more-specific lease\n> > command?)\n>\n>     git pf --force-with-lease=hotfix:origin/hotfix origin <TAG>:hotfix\n>\n> Should be sufficient and as I understand your process, that's what\n> you're after. The explicit --force-with-lease argument makes\n> --force-if-includes a no-op, so --no-force-if-includes should be\n> unnecessary.\n\nThanks, I think this answers my questions…\n\n> > Now, on the one hand, enshrining existing behavior is good for backwards\n> > compatibility but has earned us a bit of a reputation for not innovating in\n> > useful ways ;) On the other, I wonder if the description of force-if-includes\n> > allows some latitude to break with existing behavior here.\n> >\n> > The relevant docs say\n> >\n> >        --force-if-includes, --no-force-if-includes\n> >            Force an update only if the tip of the remote-tracking ref has been\n> >            integrated locally.\n> >\n> >            This option enables a check that verifies if the tip of the\n> >            remote-tracking ref is reachable from one of the \"reflog\" entries of\n> >            the local branch based in it for a rewrite. The check ensures that\n> >            any updates from the remote have been incorporated locally by\n> >            rejecting the forced update if that is not the case.\n> >\n> > It is unclear to me what \"one of the 'reflog' entries of the local branch based\n> > in it\" means! Ignoring that, the surrounding text only talks about whether the\n> > remote-tracking ref's tip (or \"updates from the remote\") have been \"integrated\n> > locally.\"\n> >\n> > So I think we *could* say that, in this case, we don't have enough information\n> > from \"<TAG>:hotfix\" to check whether \"origin/hotfix\" has been integrated locally\n> > or not, and we should tighten the meaning of the check. (Perhaps when\n> > \"--force-with-lease=hotfix\" is given, though, we now have more information\n> > available to check---but that could be outside the scope of this series if we\n> > don't mind breaking backwards compatibility now.)\n>\n> From my perspective, this is similar to the detached HEAD discussion\n> from 2020[0] where \"[the reflog of HEAD not attached to a branch]\n> _does_ answer a different question from what we actually asked.\"\n>\n> [0]: <https://lore.kernel.org/git/nycvar.QRO.7.76.6.2009161214030.56@tvgsbejvaqbjf.bet/>\n>\n> I opted for a direction requiring explicit arguments to express\n> intent, since that's the only way to ensure --force-if-includes aligns\n> with (how I read) the documentation and the previous discussions.\n>\n> Specifically, with tags:\n>\n> - tags may have a reflog, but it answers a different question vs. \"has\n> this tag integrated changes from an upstream\" it answers what oid/ref\n> does this tag point to\n> - tags may incidentally point at oids referenced by branches with\n> reflogs, but there may also be several branches pointed to the same\n> oid, so which would we choose?\n\n…and I think this makes a good case for the change (but let's see what\nothers think).\n\n> BUT I just realized there is existing, more fundamental breakage with\n> --force-if-includes here that I'm making worse.\n>\n> There is one case where we do have enough information to say whether\n> <TAG> has integrated the tip of the remote-ref locally: fast-forward\n> push. And that's actually broken today, too :)\n>\n>     git --version\n>     git version 2.47.3\n>     git clone repo.git repo && cd repo\n>     git commit --allow-empty -m 'Normal, no-force-needed fast forward commit'\n>     git reflog expire --expire=all --all\n>     # Regular fast-forward push fails, even though it does not require\n> --force to begin with\n>     git push --force-with-lease --force-if-includes origin main\n>     ! [rejected]        main -> main (remote ref updated since checkout)\n>\n> Checking for fast-forward happens after --force-if-includes checks the\n> reflog. So that will need a fix…\n>\n> My change makes an existing problem more acute, and probably requires\n> a fix before other fixes can merge. Otherwise, --force-if-includes\n> will always fail when pushing tags and detached heads, even when\n> they're fast forward changes, adding needless friction to otherwise\n> safe pushes (e.g., for tags that fast-forward a branch). So v5 will\n> require a third change that touches other functions in remote.c. :/\n\nPersonally, why --force at all then? ;) A bad habit to force things\nthat don't need it.\n\nBest,\n-- \nD. Ben Knoble\n"},{"id":"552773","messageId":"20260915233305.334115-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v5 0/3] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-15T23:33:02Z","receivedAt":"2026-09-15T23:33:33Z","isPatch":true,"body":"Changes since v4:\n\n- Add patch to series: Fix case where fast-forward pushes are being\n  rejected by --force-if-includes: an existing bug that I made worse\n  with the previous changes in my series.\n- Add tests to cover allowed fast-forward merges when using\n  --force-if-includes\n\nChanges since v3:\n\n- check_if_includes_upstream unconditionally resolves peer_ref with\n  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n  when using --force-if-includes\n- add test for --force-if-includes tag push 1/3\n- clarify log message problem example 1/3\n- clarify deletion in log message 1/3\n- add missing blank line between test cases\n- shorten long line in builtin/push.c\n- reword advice-message wording 2/3\n- rename 2/2 from \"detached HEAD\" to \"non-branch\"\n\nChanges since v2:\n\n- Correct patch threading of 1/3 and 2/3 to reply to cover letter of\n  current patchset vs. cover letter of the initial iteration.\n\nChanges since v1:\n\n- Clarify in log message 1/3 that --force-if-includes will reject a\n  detached HEAD today (when the same-named local branch lacks the remote\n  tip). And note that this change makes it explicit to always reject\n  the detached HEAD case.\n\n--force-if-includes has been checking the reflog of the local branch named\nafter the destination branch regardless of what's being pushed. This can cause\nfalse rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n               false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local branch\ncontains the remote tip but you --force-if-includes push an unrelated branch,\nclobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases\nfail against maint, but pass with patches applied.\n\nExisting tests covered refspecs with different names for --force-with-lease,\nbut missed --force-if-includes. New patches cover:\n\n- allow fast-forward push using --force-if-includes with an expired\n  reflog\n- allow fast-forward push of a tag on a different-named local branch\n- allow forced-update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch lacking\n  branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n\nResolved question: the detached HEAD case; HEAD's reflog was considered\nand rejected as too broad for purpose in the original review. cf. [2]\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n[2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>\n\nTyler Cipriani (3):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes non-branch advice\n  push: --force-if-includes should allow fast-forward\n\n Documentation/config/advice.adoc |   4 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/push.c                   |  17 +++++\n builtin/send-pack.c              |   5 ++\n remote.c                         |  43 ++++++++++--\n remote.h                         |  10 ++-\n send-pack.c                      |   1 +\n t/t5533-push-cas.sh              | 115 ++++++++++++++++++++++++++++++-\n transport-helper.c               |   5 ++\n transport.c                      |   8 +++\n transport.h                      |   1 +\n 12 files changed, 203 insertions(+), 8 deletions(-)\n\nRange-diff against v4:\n1:  e7912c3fd0 = 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n2:  2a455d8a76 = 2:  2a455d8a76 push: fix --force-if-includes non-branch advice\n-:  ---------- > 3:  1776f8d572 push: --force-if-includes should allow fast-forward\n-- \n2.47.3\n\n"},{"id":"552774","messageId":"20260915233305.334115-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260915233305.334115-1-tyler@tylercipriani.com","subject":"[PATCH v5 1/3] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-15T23:33:03Z","receivedAt":"2026-09-15T23:33:38Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nFor example, this command looks at the reflog for main vs. src, even\nthough src is being pushed:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nThis can cause confusing rejections or unintended data loss.\n\nFalse rejections: when src is up-to-date with the tip of origin's main,\nbut main is out-of-date or nonexistent, then the force-if-includes check\nwill fail, telling users the remote ref has been updated since the last\ncheckout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the force-if-includes check will allow the push,\nclobbering the remote main.\n\nInstead, use ref->peer_ref to locate a branch with a reflog. But if ref\ndoes not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\nthen we reject the push. The alternative would be to use HEAD's reflog,\nwhich is too broad to tell us if the history being pushed includes the\ntip of the remote. We need a per-branch reflog, which means that pushes\nof a ref that do not resolve to a branch are rejected. Rejecting the\npush of a ref like a detached HEAD already happens today (if the\nsame-named local branch lacks the remote tip); now the detached HEAD and\nother non-branch pushes are explicitly rejected.\n\nAllow deletions, e.g.:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nA deletion has no source ref, so no branch reflog can be checked.\nExisting tests already enforce that deletions should work with\nforce-if-includes.\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nThe early return when peer_ref is missing in check_if_includes_upstream\nis necessary because apply_push_cas walks every advertised ref whenever\nuse_tracking_for_rest is set (i.e., a bare --force-with-lease), so\ncheck_if_includes upstream is called for for refs that are not part of\nthe push.\n\nRemove unnecessary check for empty return from get_local_ref, since it\nnever returns NULL for a non-empty name.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 26 +++++++++++++--\n t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 105 insertions(+), 2 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..887c7ec00c 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n-\tif (!local)\n+\tstruct ref *local;\n+\tconst char *name;\n+\n+\t/* ref without peer_ref will not be pushed */\n+\tif (!remote->peer_ref)\n \t\treturn;\n \n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t       remote->peer_ref->name,\n+\t\t\t\t       RESOLVE_REF_READING, NULL, NULL);\n+\n+\t/*\n+\t * if we resolve the ref to anything other than a branch,\n+\t * then there is no reliable reflog to check\n+\t */\n+\tif (!name || !starts_with(name, \"refs/heads/\")) {\n+\t\tremote->unreachable = 1;\n+\t\treturn;\n+\t}\n+\n+\tlocal = get_local_ref(name);\n+\n \tif (is_reachable_in_reflog(local->name, remote) <= 0)\n \t\tremote->unreachable = 1;\n \tfree_one_ref(local);\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..265be6a84c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,85 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\tgit tag stable &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552775","messageId":"20260915233305.334115-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260915233305.334115-1-tyler@tylercipriani.com","subject":"[PATCH v5 2/3] push: fix --force-if-includes non-branch advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-15T23:33:04Z","receivedAt":"2026-09-15T23:33:43Z","isPatch":true,"body":"When a --force-if-includes push is rejected due lacking reflog to\nconsult, the advice is misleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate pushing something without a\nreflog to consult vs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 17 +++++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 11 ++++++++---\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 62 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..8d258980ff 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is integrated locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..679d9cee83 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,13 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because what you are pushing is not a branch,\\n\"\n+\t   \"so there is no reflog to check against the tip of the remote-tracking\\n\"\n+\t   \"branch. If you want to push anyway, specify the expected value with\\n\"\n+\t   \"'--force-with-lease=<ref>:<expect>' or use '--no-force-if-includes'\\n\"\n+\t   \"to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +368,14 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) ||\n+\t\t\t!advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +427,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 887c7ec00c..b7b5ac0d28 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2826,7 +2829,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t * then there is no reliable reflog to check\n \t */\n \tif (!name || !starts_with(name, \"refs/heads/\")) {\n-\t\tremote->unreachable = 1;\n+\t\tremote->unverifiable = 1;\n \t\treturn;\n \t}\n \ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 265be6a84c..38576917e4 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -458,7 +459,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \n@@ -473,7 +476,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from tag'\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n \t\tgit tag stable &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552776","messageId":"20260915233305.334115-4-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260915233305.334115-1-tyler@tylercipriani.com","subject":"[PATCH v5 3/3] push: --force-if-includes should allow fast-forward","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-15T23:33:05Z","receivedAt":"2026-09-15T23:33:47Z","isPatch":true,"body":"In set_ref_status_for_push, we verify --force-if-includes's reflog\nreachability checks before fast-forward rules. As a result, valid\nfast-forward pushes may be rejected when a force push is unneeded; like\nwhen the reflog is expired:\n\n    git clone repo.git repo\n    git commit --allow-empty -m 1\n    git reflog expire --expire=all --all\n    git push --force-with-lease --force-if-includes origin main\n    ! [rejected]    main -> main (remote ref updated since checkout)\n\nRejecting fast-forwards is a mismatch with the --force-if-includes\ndocumentation \"Force an update only if the tip of the remote-tracking\nref has been integrated locally.\"\n\nInstead, defer check for --force-if-includes until after determining if\na push force is needed.\n\nOpted to create a deferred_reject_reason in set_ref_status_for_push\nrather than move the computation of reachability or verifiability to\nwinnow scope of changes in this patch. Lazily checking for reachability\nor verifiability is a valid followup.\n\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 16 +++++++++++++---\n t/t5533-push-cas.sh | 27 +++++++++++++++++++++++++++\n 2 files changed, 40 insertions(+), 3 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex b7b5ac0d28..db0b50b030 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1669,6 +1669,7 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \tfor (ref = remote_refs; ref; ref = ref->next) {\n \t\tint force_ref_update = ref->force || force_update;\n \t\tint reject_reason = 0;\n+\t\tint deferred_reject_reason = 0;\n \n \t\tif (ref->peer_ref)\n \t\t\toidcpy(&ref->new_oid, &ref->peer_ref->new_oid);\n@@ -1693,16 +1694,17 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t *\n \t\t * If the tip of the remote-tracking ref is unreachable\n \t\t * from any reflog entry of its local ref indicating a\n-\t\t * possible update since checkout; reject the push.\n+\t\t * possible update since checkout, then remember the\n+\t\t * rejection in case the push is non-fast-forward.\n \t\t */\n \t\tif (ref->expect_old_sha1) {\n \t\t\tif (!oideq(&ref->old_oid, &ref->old_oid_expect))\n \t\t\t\treject_reason = REF_STATUS_REJECT_STALE;\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n-\t\t\t\treject_reason =\n+\t\t\t\tdeferred_reject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\telse if (ref->check_reachable && ref->unverifiable)\n-\t\t\t\treject_reason =\n+\t\t\t\tdeferred_reject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n@@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\t\treject_reason = REF_STATUS_REJECT_NONFASTFORWARD;\n \t\t}\n \n+\t\t/*\n+\t\t * If push is non-fast-forward and we were asked to\n+\t\t * verify the reflog but were unable to, then reflog\n+\t\t * verification is the right reject_reason.\n+\t\t */\n+\t\tif (deferred_reject_reason && reject_reason)\n+\t\t\treject_reason = deferred_reject_reason;\n+\n \t\t/*\n \t\t * \"--force\" will defeat any rejection implemented\n \t\t * by the rules above.\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 38576917e4..53e241c5b1 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -421,6 +421,33 @@ test_expect_success '\"--force-if-includes\" should allow forced update from HEAD'\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow fast-forward push without local reflog' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\ttest_commit I &&\n+\t\tgit reflog expire --expire=all --all &&\n+\t\tgit push --force-with-lease --force-if-includes origin main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should allow fast-forward push from tag' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\ttest_commit I &&\n+\t\tgit tag T &&\n+\t\tgit push --force-with-lease --force-if-includes origin T:main\n+\t)\n+'\n+\n test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n \tsetup_src_dup_dst &&\n \ttest_when_finished \"rm -fr dst src dup\" &&\n-- \n2.47.3\n\n"},{"id":"552784","messageId":"CALnO6CCpxwenphyZvEX7gjvAmLPidv+4iT98uh95mXj_MvshQg@mail.gmail.com","threadId":"66273","inReplyTo":"20260915233305.334115-4-tyler@tylercipriani.com","subject":"Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-16T12:29:28Z","receivedAt":"2026-09-16T12:29:40Z","isPatch":true,"body":"Hi Tyler,\n\nOn Tue, Sep 15, 2026 at 7:33 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n>\n> In set_ref_status_for_push, we verify --force-if-includes's reflog\n> reachability checks before fast-forward rules. As a result, valid\n> fast-forward pushes may be rejected when a force push is unneeded; like\n> when the reflog is expired:\n>\n>     git clone repo.git repo\n>     git commit --allow-empty -m 1\n>     git reflog expire --expire=all --all\n>     git push --force-with-lease --force-if-includes origin main\n>     ! [rejected]    main -> main (remote ref updated since checkout)\n>\n> Rejecting fast-forwards is a mismatch with the --force-if-includes\n> documentation \"Force an update only if the tip of the remote-tracking\n> ref has been integrated locally.\"\n>\n> Instead, defer check for --force-if-includes until after determining if\n> a push force is needed.\n\n\"push force\" ? :)\n\n> Opted to create a deferred_reject_reason in set_ref_status_for_push\n> rather than move the computation of reachability or verifiability to\n> winnow scope of changes in this patch. Lazily checking for reachability\n> or verifiability is a valid followup.\n\nThis paragraph does not match our usual style\n(Documentation/SubmittingPatches[[imperative-mood]]) and feels\nsomewhat artificial to me.\n\n> diff --git a/remote.c b/remote.c\n> index b7b5ac0d28..db0b50b030 100644\n> --- a/remote.c\n> +++ b/remote.c\n> @@ -1669,6 +1669,7 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n>         for (ref = remote_refs; ref; ref = ref->next) {\n>                 int force_ref_update = ref->force || force_update;\n>                 int reject_reason = 0;\n> +               int deferred_reject_reason = 0;\n>\n>                 if (ref->peer_ref)\n>                         oidcpy(&ref->new_oid, &ref->peer_ref->new_oid);\n> @@ -1693,16 +1694,17 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n>                  *\n>                  * If the tip of the remote-tracking ref is unreachable\n>                  * from any reflog entry of its local ref indicating a\n> -                * possible update since checkout; reject the push.\n> +                * possible update since checkout, then remember the\n> +                * rejection in case the push is non-fast-forward.\n>                  */\n>                 if (ref->expect_old_sha1) {\n>                         if (!oideq(&ref->old_oid, &ref->old_oid_expect))\n>                                 reject_reason = REF_STATUS_REJECT_STALE;\n>                         else if (ref->check_reachable && ref->unreachable)\n> -                               reject_reason =\n> +                               deferred_reject_reason =\n>                                         REF_STATUS_REJECT_REMOTE_UPDATED;\n>                         else if (ref->check_reachable && ref->unverifiable)\n> -                               reject_reason =\n> +                               deferred_reject_reason =\n>                                         REF_STATUS_REJECT_UNVERIFIABLE;\n>                         else\n>                                 /*\n\nFrom these 2 hunks, I haven't yet seen the connection to avoiding a\nrejected force-push in the fast-forward case, but my read is: we\nremember why we might reject a force-push for refs whose reachability\nwe are supposed to check.\n\n> @@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n>                                 reject_reason = REF_STATUS_REJECT_NONFASTFORWARD;\n>                 }\n\nThen in unshown code, in those 2 \"remembered\" cases, we check the must\nfast-forward rules. If any fail, we set reject_reason…\n\n> +               /*\n> +                * If push is non-fast-forward and we were asked to\n> +                * verify the reflog but were unable to, then reflog\n> +                * verification is the right reject_reason.\n> +                */\n> +               if (deferred_reject_reason && reject_reason)\n> +                       reject_reason = deferred_reject_reason;\n> +\n\n…which we now overwrite with our remembered reason in the rejected\ncase. I think that makes sense.\n\nAt first I thought the unshown code above, conditional on\n!reject_reason, would collude to make it so \"deferred_reject_reason &&\nreject_reason\" could never be true, but I was misreading the results\nof this patch. There are some arms in which we set both (namely,\nbecause those remembered cases don't set reject_reason, allowing the\nfast-forward rules checks).\n\nI still wonder a bit about cases where we remember\ndeferred_reject_reason and never set reject_reason, but I think those\nare supposed to only be the fast-forward cases. Perhaps we want to\nmake \"deferred_reject_reason\" more clearly indicate that to save\nfuture readers headache if they insert code around here? I'm not sure\nthe best way to do that, though, so maybe blaming to the log message\nwill suffice.\n\n-- \nD. Ben Knoble\n"},{"id":"552790","messageId":"CAHLx=On9sSTR+Ei2FmV7YSDtJ85SAzL3x=ALns1vsotn6c8Fiw@mail.gmail.com","threadId":"66273","inReplyTo":"CALnO6CCpxwenphyZvEX7gjvAmLPidv+4iT98uh95mXj_MvshQg@mail.gmail.com","subject":"Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-16T15:52:34Z","receivedAt":"2026-09-16T15:52:48Z","isPatch":true,"body":"On Wed, Sep 16, 2026 at 6:29 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:\n>\n> Hi Tyler,\n>\n> On Tue, Sep 15, 2026 at 7:33 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:\n> >\n> > In set_ref_status_for_push, we verify --force-if-includes's reflog\n> > reachability checks before fast-forward rules. As a result, valid\n> > fast-forward pushes may be rejected when a force push is unneeded; like\n> > when the reflog is expired:\n> >\n> >     git clone repo.git repo\n> >     git commit --allow-empty -m 1\n> >     git reflog expire --expire=all --all\n> >     git push --force-with-lease --force-if-includes origin main\n> >     ! [rejected]    main -> main (remote ref updated since checkout)\n> >\n> > Rejecting fast-forwards is a mismatch with the --force-if-includes\n> > documentation \"Force an update only if the tip of the remote-tracking\n> > ref has been integrated locally.\"\n> >\n> > Instead, defer check for --force-if-includes until after determining if\n> > a push force is needed.\n>\n> \"push force\" ? :)\n\nWhoops, good catch, thanks!\n\n> > Opted to create a deferred_reject_reason in set_ref_status_for_push\n> > rather than move the computation of reachability or verifiability to\n> > winnow scope of changes in this patch. Lazily checking for reachability\n> > or verifiability is a valid followup.\n>\n> This paragraph does not match our usual style\n> (Documentation/SubmittingPatches[[imperative-mood]]) and feels\n> somewhat artificial to me.\n\nAck, I can update the mood. My goal was to make a note that moving the\nreachability check seems possible and might be a decent idea, but it's\na lot of change in one patch.\n\n> > diff --git a/remote.c b/remote.c\n> > index b7b5ac0d28..db0b50b030 100644\n> > --- a/remote.c\n> > +++ b/remote.c\n> > @@ -1669,6 +1669,7 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n> >         for (ref = remote_refs; ref; ref = ref->next) {\n> >                 int force_ref_update = ref->force || force_update;\n> >                 int reject_reason = 0;\n> > +               int deferred_reject_reason = 0;\n> >\n> >                 if (ref->peer_ref)\n> >                         oidcpy(&ref->new_oid, &ref->peer_ref->new_oid);\n> > @@ -1693,16 +1694,17 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n> >                  *\n> >                  * If the tip of the remote-tracking ref is unreachable\n> >                  * from any reflog entry of its local ref indicating a\n> > -                * possible update since checkout; reject the push.\n> > +                * possible update since checkout, then remember the\n> > +                * rejection in case the push is non-fast-forward.\n> >                  */\n> >                 if (ref->expect_old_sha1) {\n> >                         if (!oideq(&ref->old_oid, &ref->old_oid_expect))\n> >                                 reject_reason = REF_STATUS_REJECT_STALE;\n> >                         else if (ref->check_reachable && ref->unreachable)\n> > -                               reject_reason =\n> > +                               deferred_reject_reason =\n> >                                         REF_STATUS_REJECT_REMOTE_UPDATED;\n> >                         else if (ref->check_reachable && ref->unverifiable)\n> > -                               reject_reason =\n> > +                               deferred_reject_reason =\n> >                                         REF_STATUS_REJECT_UNVERIFIABLE;\n> >                         else\n> >                                 /*\n>\n> From these 2 hunks, I haven't yet seen the connection to avoiding a\n> rejected force-push in the fast-forward case, but my read is: we\n> remember why we might reject a force-push for refs whose reachability\n> we are supposed to check.\n>\n> > @@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n> >                                 reject_reason = REF_STATUS_REJECT_NONFASTFORWARD;\n> >                 }\n>\n> Then in unshown code, in those 2 \"remembered\" cases, we check the must\n> fast-forward rules. If any fail, we set reject_reason…\n>\n> > +               /*\n> > +                * If push is non-fast-forward and we were asked to\n> > +                * verify the reflog but were unable to, then reflog\n> > +                * verification is the right reject_reason.\n> > +                */\n> > +               if (deferred_reject_reason && reject_reason)\n> > +                       reject_reason = deferred_reject_reason;\n> > +\n>\n> …which we now overwrite with our remembered reason in the rejected\n> case. I think that makes sense.\n>\n> At first I thought the unshown code above, conditional on\n> !reject_reason, would collude to make it so \"deferred_reject_reason &&\n> reject_reason\" could never be true, but I was misreading the results\n> of this patch. There are some arms in which we set both (namely,\n> because those remembered cases don't set reject_reason, allowing the\n> fast-forward rules checks).\n>\n> I still wonder a bit about cases where we remember\n> deferred_reject_reason and never set reject_reason, but I think those\n> are supposed to only be the fast-forward cases.\n\nThat's correct to me, too.\n\nI hemmed and hawed a bit about whether to only check _some of_ the\nreject_reasons from the fast-forward check. But decided that the\nadvice in 2/3 would get people to the right outcome in cases I could\nthink of.\n\n> Perhaps we want to\n> make \"deferred_reject_reason\" more clearly indicate that to save\n> future readers headache if they insert code around here? I'm not sure\n> the best way to do that, though, so maybe blaming to the log message\n> will suffice.\n\nI tried to indicate the rationale with comments, but I'm open to\nchanging the variable name, too. I felt that the \"deferred\" in the\nname captured it, but the name also feels a little broad vs. what it\ndoes.\n\nBefore I take a stab at a reroll for commit message updates + variable\nnames, I'd like to gather more feedback on the direction and\nimplementation of this series.\n\nThanks you for your thoughtful comments, Ben! I've appreciated how\nyou've helped me think about this feature.\n"},{"id":"552794","messageId":"1BCA7C21-AC51-4706-9F2B-27BF2E94A13B@gmail.com","threadId":"66273","inReplyTo":"CAHLx=On9sSTR+Ei2FmV7YSDtJ85SAzL3x=ALns1vsotn6c8Fiw@mail.gmail.com","subject":"Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-16T17:53:03Z","receivedAt":"2026-09-16T17:53:18Z","isPatch":true,"body":"\n> \n> Le 16 sept. 2026 à 11:52, Tyler Cipriani <tyler@tylercipriani.com> a écrit :\n> \n> ﻿On Wed, Sep 16, 2026 at 6:29 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:\n>> \n>> Hi Tyler,\n\n[snip]\n\n>> Perhaps we want to\n>> make \"deferred_reject_reason\" more clearly indicate that to save\n>> future readers headache if they insert code around here? I'm not sure\n>> the best way to do that, though, so maybe blaming to the log message\n>> will suffice.\n> \n> I tried to indicate the rationale with comments, but I'm open to\n> changing the variable name, too. I felt that the \"deferred\" in the\n> name captured it, but the name also feels a little broad vs. what it\n> does.\n> \n> Before I take a stab at a reroll for commit message updates + variable\n> names, I'd like to gather more feedback on the direction and\n> implementation of this series.\n\nYes, I think that’s a good idea :)\n\n> Thanks you for your thoughtful comments, Ben! I've appreciated how\n> you've helped me think about this feature.\n\nYou’re quite welcome, thank you!"},{"id":"552838","messageId":"20260917224351.57171-1-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260904210122.431757-1-tyler@tylercipriani.com","subject":"[PATCH v6 0/3] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-17T22:43:48Z","receivedAt":"2026-09-17T22:43:55Z","isPatch":true,"body":"Changes since v5:\n\n- Update reflog rejection variable name to signal its use\n  s/deferred_reject_reason/needs_force_reject_reason 3/3\n- Typo fix: s/push force/force push/ in log message 3/3\n- Remove imperative mood from log message 3/3\n- Edit log message/comments for clarity and ensure the terms used for\n  each agree 3/3\n- Restored missing function words \"to\" and \"a\" in log message 2/3\n\nChanges since v4:\n\n- Add patch to series: Fix case where fast-forward pushes are being\n  rejected by --force-if-includes: an existing bug that I made worse\n  with the previous changes in my series.\n- Add tests to cover allowed fast-forward merges when using\n  --force-if-includes\n\nChanges since v3:\n\n- check_if_includes_upstream unconditionally resolves peer_ref with\n  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected\n  when using --force-if-includes\n- add test for --force-if-includes tag push 1/3\n- clarify log message problem example 1/3\n- clarify deletion in log message 1/3\n- add missing blank line between test cases\n- shorten long line in builtin/push.c\n- reword advice-message wording 2/3\n- rename 2/3 from \"detached HEAD\" to \"non-branch\"\n\nChanges since v2:\n\n- Correct patch threading of 1/3 and 2/3 to reply to cover letter of\n  current patchset vs. cover letter of the initial iteration.\n\nChanges since v1:\n\n- Clarify in log message 1/3 that --force-if-includes will reject a\n  detached HEAD today (when the same-named local branch lacks the remote\n  tip). And note that this change makes it explicit to always reject the\n  detached HEAD case.\n\n--force-if-includes has been checking the reflog of the local branch\nnamed after the destination branch regardless of what's being pushed.\nThis can cause false rejections or unintended data loss.\n\nFalse rejection has been reported twice that I could find:\n\n- 2023-07-26 - Stefan Haller reported local branch with a different name\n  false rejection[0]\n- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]\n\nThe same root cause can result in data loss: when a same-name local\nbranch contains the remote tip but you --force-if-includes push an\nunrelated branch, clobbering the remote repo. PoCs are in\nt/t5533-push-cas.sh -- new test cases fail against maint, but pass with\npatches applied.\n\nExisting tests covered refspecs with different names for\n--force-with-lease, but missed --force-if-includes. New patches cover:\n\n- allow fast-forward push using --force-if-includes with an expired\n  reflog\n- allow fast-forward push of a tag on a different-named local branch\n- allow forced update using refspec with different-named local branch\n- allow same as above, but with HEAD\n- reject force-update using refspec with different-named local branch\n  lacking branch tip\n- reject same as above using HEAD\n- reject detached HEAD\n- reject tag\n- allow fast-forward push with an expired reflog\n- allow fast-forward push from a tag\n\nResolved question: the detached HEAD case; HEAD's reflog was considered\nand rejected as too broad for purpose in the original review. cf. [2]\n\nReject non-branch pushes due to lacking suitable reflogs for\n--force-if-includes to determine if remote was integrated into ref being\npushed.\n\nFixes false rejections for pushes which never required force when using\n--force-if-includes.\n\n[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>\n[1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>\n[2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>\n\nTyler Cipriani (3):\n  push: check pushed ref for --force-if-includes\n  push: fix --force-if-includes non-branch advice\n  push: --force-if-includes should allow fast-forward\n\n Documentation/config/advice.adoc |   4 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/push.c                   |  17 +++++\n builtin/send-pack.c              |   5 ++\n remote.c                         |  43 ++++++++++--\n remote.h                         |  10 ++-\n send-pack.c                      |   1 +\n t/t5533-push-cas.sh              | 115 ++++++++++++++++++++++++++++++-\n transport-helper.c               |   5 ++\n transport.c                      |   8 +++\n transport.h                      |   1 +\n 12 files changed, 203 insertions(+), 8 deletions(-)\n\nRange-diff against v5:\n1:  e7912c3fd0 = 1:  e7912c3fd0 push: check pushed ref for --force-if-includes\n2:  2a455d8a76 ! 2:  f85041efa8 push: fix --force-if-includes non-branch advice\n    @@ Metadata\n      ## Commit message ##\n         push: fix --force-if-includes non-branch advice\n     \n    -    When a --force-if-includes push is rejected due lacking reflog to\n    +    When a --force-if-includes push is rejected due to lacking a reflog to\n         consult, the advice is misleading:\n     \n              ! [rejected] HEAD -> main (remote ref updated since checkout)\n3:  1776f8d572 ! 3:  f9d97b0644 push: --force-if-includes should allow fast-forward\n    @@ Metadata\n      ## Commit message ##\n         push: --force-if-includes should allow fast-forward\n     \n    -    In set_ref_status_for_push, we verify --force-if-includes's reflog\n    +    In set_ref_status_for_push, we apply --force-if-includes's reflog\n         reachability checks before fast-forward rules. As a result, valid\n         fast-forward pushes may be rejected when a force push is unneeded; like\n         when the reflog is expired:\n    @@ Commit message\n         documentation \"Force an update only if the tip of the remote-tracking\n         ref has been integrated locally.\"\n     \n    -    Instead, defer check for --force-if-includes until after determining if\n    -    a push force is needed.\n    +    Instead, defer reflog rejection for --force-if-includes until after\n    +    determining if a force push is needed.\n     \n    -    Opted to create a deferred_reject_reason in set_ref_status_for_push\n    -    rather than move the computation of reachability or verifiability to\n    -    winnow scope of changes in this patch. Lazily checking for reachability\n    -    or verifiability is a valid followup.\n    +    Remember the reflog rejection reason as needs_force_reject_reason. If\n    +    the fast-forward rules reject the push for a ref, show the reflog\n    +    rejection reason to preserve existing behavior. But if fast-forward\n    +    rules allow a push (a fast-forward, deletion, or new ref), then a force\n    +    push is unneeded, the reflog rejection reason is discarded, and the push\n    +    proceeds.\n     \n         Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n     \n    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n      \tfor (ref = remote_refs; ref; ref = ref->next) {\n      \t\tint force_ref_update = ref->force || force_update;\n      \t\tint reject_reason = 0;\n    -+\t\tint deferred_reject_reason = 0;\n    ++\t\tint needs_force_reject_reason = 0;\n      \n      \t\tif (ref->peer_ref)\n      \t\t\toidcpy(&ref->new_oid, &ref->peer_ref->new_oid);\n    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n      \t\t * from any reflog entry of its local ref indicating a\n     -\t\t * possible update since checkout; reject the push.\n     +\t\t * possible update since checkout, then remember the\n    -+\t\t * rejection in case the push is non-fast-forward.\n    ++\t\t * rejection in case force push is needed.\n      \t\t */\n      \t\tif (ref->expect_old_sha1) {\n      \t\t\tif (!oideq(&ref->old_oid, &ref->old_oid_expect))\n      \t\t\t\treject_reason = REF_STATUS_REJECT_STALE;\n      \t\t\telse if (ref->check_reachable && ref->unreachable)\n     -\t\t\t\treject_reason =\n    -+\t\t\t\tdeferred_reject_reason =\n    ++\t\t\t\tneeds_force_reject_reason =\n      \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n      \t\t\telse if (ref->check_reachable && ref->unverifiable)\n     -\t\t\t\treject_reason =\n    -+\t\t\t\tdeferred_reject_reason =\n    ++\t\t\t\tneeds_force_reject_reason =\n      \t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n      \t\t\telse\n      \t\t\t\t/*\n    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n      \t\t}\n      \n     +\t\t/*\n    -+\t\t * If push is non-fast-forward and we were asked to\n    -+\t\t * verify the reflog but were unable to, then reflog\n    -+\t\t * verification is the right reject_reason.\n    ++\t\t * If fast-forward rules rejected the push and we were\n    ++\t\t * asked to verify the reflog but were unable to, then\n    ++\t\t * reflog verification is the right reject_reason.\n     +\t\t */\n    -+\t\tif (deferred_reject_reason && reject_reason)\n    -+\t\t\treject_reason = deferred_reject_reason;\n    ++\t\tif (needs_force_reject_reason && reject_reason)\n    ++\t\t\treject_reason = needs_force_reject_reason;\n     +\n      \t\t/*\n      \t\t * \"--force\" will defeat any rejection implemented\n-- \n2.47.3\n\n"},{"id":"552839","messageId":"20260917224351.57171-2-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260917224351.57171-1-tyler@tylercipriani.com","subject":"[PATCH v6 1/3] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-17T22:43:49Z","receivedAt":"2026-09-17T22:43:59Z","isPatch":true,"body":"\"--force-if-includes\" ensures, \"tip of the remote-tracking ref is\nreachable from one of the 'reflog' entries of the local branch.\"\n\nBut check_if_includes_upstream() uses the local per-branch reflog based\non the destination branch rather than the branch being pushed; using\nref->name vs. ref->peer_ref->name.\n\nFor example, this command looks at the reflog for main vs. src, even\nthough src is being pushed:\n\n    git push --force-if-includes --force-with-lease origin src:main\n\nThis can cause confusing rejections or unintended data loss.\n\nFalse rejections: when src is up-to-date with the tip of origin's main,\nbut main is out-of-date or nonexistent, then the force-if-includes check\nwill fail, telling users the remote ref has been updated since the last\ncheckout.\n\nData loss: when src is an orphan/out-dated branch, but main is\nup-to-date, then the force-if-includes check will allow the push,\nclobbering the remote main.\n\nInstead, use ref->peer_ref to locate a branch with a reflog. But if ref\ndoes not resolve to a branch (e.g., a detached HEAD, a tag, an oid),\nthen we reject the push. The alternative would be to use HEAD's reflog,\nwhich is too broad to tell us if the history being pushed includes the\ntip of the remote. We need a per-branch reflog, which means that pushes\nof a ref that do not resolve to a branch are rejected. Rejecting the\npush of a ref like a detached HEAD already happens today (if the\nsame-named local branch lacks the remote tip); now the detached HEAD and\nother non-branch pushes are explicitly rejected.\n\nAllow deletions, e.g.:\n\n    git push --force-if-includes --force-with-lease origin :main\n\nA deletion has no source ref, so no branch reflog can be checked.\nExisting tests already enforce that deletions should work with\nforce-if-includes.\n\nref->deletion is set after apply_push_cas (which triggers\ncheck_if_includes_upstream). The ref->peer_ref name is \"(delete)\".\nInstead check with is_null_oid to detect and allow deletion.\n\nThe early return when peer_ref is missing in check_if_includes_upstream\nis necessary because apply_push_cas walks every advertised ref whenever\nuse_tracking_for_rest is set (i.e., a bare --force-with-lease), so\ncheck_if_includes upstream is called for for refs that are not part of\nthe push.\n\nRemove unnecessary check for empty return from get_local_ref, since it\nnever returns NULL for a non-empty name.\n\nReported-by: Stefan Haller <lists@haller-berlin.de>\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 26 +++++++++++++--\n t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 105 insertions(+), 2 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 00723b385e..887c7ec00c 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)\n  */\n static void check_if_includes_upstream(struct ref *remote)\n {\n-\tstruct ref *local = get_local_ref(remote->name);\n-\tif (!local)\n+\tstruct ref *local;\n+\tconst char *name;\n+\n+\t/* ref without peer_ref will not be pushed */\n+\tif (!remote->peer_ref)\n \t\treturn;\n \n+\t/* A deletion has no local history to check against. */\n+\tif (is_null_oid(&remote->peer_ref->new_oid))\n+\t\treturn;\n+\n+\tname = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t       remote->peer_ref->name,\n+\t\t\t\t       RESOLVE_REF_READING, NULL, NULL);\n+\n+\t/*\n+\t * if we resolve the ref to anything other than a branch,\n+\t * then there is no reliable reflog to check\n+\t */\n+\tif (!name || !starts_with(name, \"refs/heads/\")) {\n+\t\tremote->unreachable = 1;\n+\t\treturn;\n+\t}\n+\n+\tlocal = get_local_ref(name);\n+\n \tif (is_reachable_in_reflog(local->name, remote) <= 0)\n \t\tremote->unreachable = 1;\n \tfree_one_ref(local);\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex cba26a872d..265be6a84c 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -396,4 +396,85 @@ test_expect_success '\"--force-if-includes\" should allow deletes' '\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow forced update when using differently named branches' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin newbranch:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should allow forced update from HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit rebase HEAD --onto HEAD^ &&\n+\t\tgit push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin orphan:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from HEAD when it lacks remote ref' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch --orphan orphan &&\n+\t\ttest_commit I &&\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from detached HEAD' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should reject forced update from tag' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\tgit checkout HEAD^ &&\n+\t\tgit tag stable &&\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t)\n+'\n+\n test_done\n-- \n2.47.3\n\n"},{"id":"552840","messageId":"20260917224351.57171-3-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260917224351.57171-1-tyler@tylercipriani.com","subject":"[PATCH v6 2/3] push: fix --force-if-includes non-branch advice","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-17T22:43:50Z","receivedAt":"2026-09-17T22:44:06Z","isPatch":true,"body":"When a --force-if-includes push is rejected due to lacking a reflog to\nconsult, the advice is misleading:\n\n     ! [rejected] HEAD -> main (remote ref updated since checkout)\n    error: failed to push some refs to '<remote>'\n    hint: Updates were rejected because the tip of the remote-tracking\n    hint: branch has been updated since the last checkout. If you want\n    hint: to integrate the remote changes, use 'git pull' before\n    hint: pushing again. See the 'Note about fast-forwards' in 'git\n    hint: push --help' for details.\n\nBut a `git pull` will not fix this rejection. What is required is either\n\n- Specify the expected remote tip with --force-with-lease=<ref>:<expect>\n- Ignore the error with --no-force-if-includes\n\nAdd ref->unverifiable to differentiate pushing something without a\nreflog to consult vs. a remote update rejection.\n\nEnsure tests check the rejection message.\n\nReported-by: D. Ben Knoble <ben.knoble@gmail.com>\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n Documentation/config/advice.adoc |  4 ++++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/push.c                   | 17 +++++++++++++++++\n builtin/send-pack.c              |  5 +++++\n remote.c                         |  5 ++++-\n remote.h                         | 10 +++++++---\n send-pack.c                      |  1 +\n t/t5533-push-cas.sh              | 11 ++++++++---\n transport-helper.c               |  5 +++++\n transport.c                      |  8 ++++++++\n transport.h                      |  1 +\n 12 files changed, 62 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..8d258980ff 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -90,6 +90,10 @@ all advice messages.\n \t\tShown when linkgit:git-push[1] rejects a forced update of\n \t\ta branch when its remote-tracking ref has updates that we\n \t\tdo not have locally.\n+\tpushRefUnverifiable::\n+\t\tShown when linkgit:git-push[1] rejects a forced update of\n+\t\ta branch when we are unable to verify the remote-tracking\n+\t\tref is integrated locally.\n \tpushUnqualifiedRefname::\n \t\tShown when linkgit:git-push[1] gives up trying to\n \t\tguess based on the source and destination refs what\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..08842deb66 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -69,6 +69,7 @@ static struct {\n \t[ADVICE_PUSH_NON_FF_CURRENT]\t\t\t= { \"pushNonFFCurrent\" },\n \t[ADVICE_PUSH_NON_FF_MATCHING]\t\t\t= { \"pushNonFFMatching\" },\n \t[ADVICE_PUSH_REF_NEEDS_UPDATE]\t\t\t= { \"pushRefNeedsUpdate\" },\n+\t[ADVICE_PUSH_REF_UNVERIFIABLE]\t\t\t= { \"pushRefUnverifiable\" },\n \t[ADVICE_PUSH_UNQUALIFIED_REF_NAME]\t\t= { \"pushUnqualifiedRefName\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED]\t\t\t= { \"pushUpdateRejected\" },\n \t[ADVICE_PUSH_UPDATE_REJECTED_ALIAS]\t\t= { \"pushNonFastForward\" }, /* backwards compatibility */\ndiff --git a/advice.h b/advice.h\nindex 8def280688..189eadc089 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -36,6 +36,7 @@ enum advice_type {\n \tADVICE_PUSH_NON_FF_CURRENT,\n \tADVICE_PUSH_NON_FF_MATCHING,\n \tADVICE_PUSH_REF_NEEDS_UPDATE,\n+\tADVICE_PUSH_REF_UNVERIFIABLE,\n \tADVICE_PUSH_UNQUALIFIED_REF_NAME,\n \tADVICE_PUSH_UPDATE_REJECTED,\n \tADVICE_PUSH_UPDATE_REJECTED_ALIAS,\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 6021b71d66..679d9cee83 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -319,6 +319,13 @@ static const char message_advice_ref_needs_update[] =\n \t   \"remote changes, use 'git pull' before pushing again.\\n\"\n \t   \"See the 'Note about fast-forwards' in 'git push --help' for details.\");\n \n+static const char message_advice_ref_unverifiable[] =\n+\tN_(\"Updates were rejected because what you are pushing is not a branch,\\n\"\n+\t   \"so there is no reflog to check against the tip of the remote-tracking\\n\"\n+\t   \"branch. If you want to push anyway, specify the expected value with\\n\"\n+\t   \"'--force-with-lease=<ref>:<expect>' or use '--no-force-if-includes'\\n\"\n+\t   \"to skip this check.\");\n+\n static void advise_pull_before_push(void)\n {\n \tif (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n@@ -361,6 +368,14 @@ static void advise_ref_needs_update(void)\n \tadvise(_(message_advice_ref_needs_update));\n }\n \n+static void advise_ref_unverifiable(void)\n+{\n+\tif (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) ||\n+\t\t\t!advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))\n+\t\treturn;\n+\tadvise(_(message_advice_ref_unverifiable));\n+}\n+\n static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\t\t     int flags)\n {\n@@ -412,6 +427,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs,\n \t\tadvise_ref_needs_force();\n \t} else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) {\n \t\tadvise_ref_needs_update();\n+\t} else if (reject_reasons & REJECT_REF_UNVERIFIABLE) {\n+\t\tadvise_ref_unverifiable();\n \t}\n \n \treturn 1;\ndiff --git a/builtin/send-pack.c b/builtin/send-pack.c\nindex 1412b49bc8..07accb6e6b 100644\n--- a/builtin/send-pack.c\n+++ b/builtin/send-pack.c\n@@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref)\n \t\t\tmsg = \"remote ref updated since checkout\";\n \t\t\tbreak;\n \n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\t\tres = \"error\";\n+\t\t\tmsg = \"remote ref unverifiable\";\n+\t\t\tbreak;\n+\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\t\tres = \"error\";\n \t\t\tmsg = \"already exists\";\ndiff --git a/remote.c b/remote.c\nindex 887c7ec00c..b7b5ac0d28 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n \t\t\t\treject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n+\t\t\telse if (ref->check_reachable && ref->unverifiable)\n+\t\t\t\treject_reason =\n+\t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n \t\t\t\t * If the ref isn't stale, and is reachable\n@@ -2826,7 +2829,7 @@ static void check_if_includes_upstream(struct ref *remote)\n \t * then there is no reliable reflog to check\n \t */\n \tif (!name || !starts_with(name, \"refs/heads/\")) {\n-\t\tremote->unreachable = 1;\n+\t\tremote->unverifiable = 1;\n \t\treturn;\n \t}\n \ndiff --git a/remote.h b/remote.h\nindex 54b17e4b02..8e2d56c2c2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -169,10 +169,13 @@ struct ref {\n \t\t/* Need to check if local reflog reaches the remote tip. */\n \t\tcheck_reachable:1,\n \t\t/*\n-\t\t * Store the result of the check enabled by \"check_reachable\";\n-\t\t * implies the local reflog does not reach the remote tip.\n+\t\t * Store the result of the check enabled by \"check_reachable\".\n+\t\t * \"unreachable\" implies the local reflog does not reach the remote\n+\t\t * tip. \"unverifiable\" implies no local branch reflog to check; i.e.,\n+\t\t * detached HEAD.\n \t\t */\n-\t\tunreachable:1;\n+\t\tunreachable:1,\n+\t\tunverifiable:1;\n \n \tenum {\n \t\tREF_NOT_MATCHED = 0, /* initial value */\n@@ -203,6 +206,7 @@ struct ref {\n \t\tREF_STATUS_REJECT_STALE,\n \t\tREF_STATUS_REJECT_SHALLOW,\n \t\tREF_STATUS_REJECT_REMOTE_UPDATED,\n+\t\tREF_STATUS_REJECT_UNVERIFIABLE,\n \t\tREF_STATUS_UPTODATE,\n \t\tREF_STATUS_REMOTE_REJECT,\n \t\tREF_STATUS_EXPECTING_REPORT,\ndiff --git a/send-pack.c b/send-pack.c\nindex 3bb5afc687..6b78470f37 100644\n--- a/send-pack.c\n+++ b/send-pack.c\n@@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar\n \tcase REF_STATUS_REJECT_NEEDS_FORCE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_NODELETE:\n \t\treturn CHECK_REF_STATUS_REJECTED;\n \tcase REF_STATUS_UPTODATE:\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 265be6a84c..38576917e4 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with \"--force\n \t\tgit switch main &&\n \t\ttest_commit J &&\n \t\tgit fetch --all &&\n-\t\ttest_must_fail git push --force-with-lease --force-if-includes --all\n+\t\ttest_must_fail git push --force-with-lease --force-if-includes --all 2>err &&\n+\t\ttest_grep \"remote ref updated since checkout\" err\n \t) &&\n \tgit ls-remote dst refs/heads/main >actual.main &&\n \tgit ls-remote dst refs/heads/branch >actual.branch &&\n@@ -458,7 +459,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from deta\n \t\tgit reset --hard origin/main &&\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \n@@ -473,7 +476,9 @@ test_expect_success '\"--force-if-includes\" should reject forced update from tag'\n \t\tgit switch -c newbranch origin/main &&\n \t\tgit checkout HEAD^ &&\n \t\tgit tag stable &&\n-\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main\n+\t\ttest_must_fail git push --force-if-includes --force-with-lease origin stable:main 2>err &&\n+\t\ttest_grep \"remote ref unverifiable\" err &&\n+\t\ttest_grep \"no-force-if-includes\" err\n \t)\n '\n \ndiff --git a/transport-helper.c b/transport-helper.c\nindex 80f90eb7ba..1763570352 100644\n--- a/transport-helper.c\n+++ b/transport-helper.c\n@@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf,\n \t\t\tstatus = REF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\tFREE_AND_NULL(msg);\n \t\t}\n+\t\telse if (!strcmp(msg, \"remote ref unverifiable\")) {\n+\t\t\tstatus = REF_STATUS_REJECT_UNVERIFIABLE;\n+\t\t\tFREE_AND_NULL(msg);\n+\t\t}\n \t\telse if (!strcmp(msg, \"forced update\")) {\n \t\t\tforced = 1;\n \t\t\tFREE_AND_NULL(msg);\n@@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport,\n \t\tcase REF_STATUS_REJECT_STALE:\n \t\tcase REF_STATUS_REJECT_ALREADY_EXISTS:\n \t\tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\t\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \t\t\tif (atomic) {\n \t\t\t\treject_atomic_push(remote_refs, mirror);\n \t\t\t\tstring_list_clear(&cas_options, 0);\ndiff --git a/transport.c b/transport.c\nindex 0f5ec30247..3d60d6de54 100644\n--- a/transport.c\n+++ b/transport.c\n@@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count,\n \t\t\t\t \"remote ref updated since checkout\",\n \t\t\t\t report, porcelain, summary_width);\n \t\tbreak;\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n+\t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n+\t\t\t\t \"remote ref unverifiable\",\n+\t\t\t\t report, porcelain, summary_width);\n+\t\tbreak;\n \tcase REF_STATUS_REJECT_SHALLOW:\n \t\tprint_ref_status('!', \"[rejected]\", ref, ref->peer_ref,\n \t\t\t\t \"new shallow roots not allowed\",\n@@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs,\n \t\t\t*reject_reasons |= REJECT_NEEDS_FORCE;\n \t\t} else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) {\n \t\t\t*reject_reasons |= REJECT_REF_NEEDS_UPDATE;\n+\t\t} else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) {\n+\t\t\t*reject_reasons |= REJECT_REF_UNVERIFIABLE;\n \t\t}\n \t}\n \tfree(head);\n@@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void\n \tswitch (r->status) {\n \tcase REF_STATUS_REJECT_NONFASTFORWARD:\n \tcase REF_STATUS_REJECT_REMOTE_UPDATED:\n+\tcase REF_STATUS_REJECT_UNVERIFIABLE:\n \tcase REF_STATUS_REJECT_STALE:\n \tcase REF_STATUS_UPTODATE:\n \t\treturn 0; /* skip refs which won't be pushed */\ndiff --git a/transport.h b/transport.h\nindex 7e5867cffa..eaa3b616ee 100644\n--- a/transport.h\n+++ b/transport.h\n@@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,\n #define REJECT_FETCH_FIRST      0x08\n #define REJECT_NEEDS_FORCE      0x10\n #define REJECT_REF_NEEDS_UPDATE 0x20\n+#define REJECT_REF_UNVERIFIABLE 0x40\n \n int transport_push(struct repository *repo,\n \t\t   struct transport *connection,\n-- \n2.47.3\n\n"},{"id":"552841","messageId":"20260917224351.57171-4-tyler@tylercipriani.com","threadId":"66273","inReplyTo":"20260917224351.57171-1-tyler@tylercipriani.com","subject":"[PATCH v6 3/3] push: --force-if-includes should allow fast-forward","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-09-17T22:43:51Z","receivedAt":"2026-09-17T22:44:10Z","isPatch":true,"body":"In set_ref_status_for_push, we apply --force-if-includes's reflog\nreachability checks before fast-forward rules. As a result, valid\nfast-forward pushes may be rejected when a force push is unneeded; like\nwhen the reflog is expired:\n\n    git clone repo.git repo\n    git commit --allow-empty -m 1\n    git reflog expire --expire=all --all\n    git push --force-with-lease --force-if-includes origin main\n    ! [rejected]    main -> main (remote ref updated since checkout)\n\nRejecting fast-forwards is a mismatch with the --force-if-includes\ndocumentation \"Force an update only if the tip of the remote-tracking\nref has been integrated locally.\"\n\nInstead, defer reflog rejection for --force-if-includes until after\ndetermining if a force push is needed.\n\nRemember the reflog rejection reason as needs_force_reject_reason. If\nthe fast-forward rules reject the push for a ref, show the reflog\nrejection reason to preserve existing behavior. But if fast-forward\nrules allow a push (a fast-forward, deletion, or new ref), then a force\npush is unneeded, the reflog rejection reason is discarded, and the push\nproceeds.\n\nSigned-off-by: Tyler Cipriani <tyler@tylercipriani.com>\n---\n remote.c            | 16 +++++++++++++---\n t/t5533-push-cas.sh | 27 +++++++++++++++++++++++++++\n 2 files changed, 40 insertions(+), 3 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex b7b5ac0d28..1ea1d2209c 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1669,6 +1669,7 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \tfor (ref = remote_refs; ref; ref = ref->next) {\n \t\tint force_ref_update = ref->force || force_update;\n \t\tint reject_reason = 0;\n+\t\tint needs_force_reject_reason = 0;\n \n \t\tif (ref->peer_ref)\n \t\t\toidcpy(&ref->new_oid, &ref->peer_ref->new_oid);\n@@ -1693,16 +1694,17 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t *\n \t\t * If the tip of the remote-tracking ref is unreachable\n \t\t * from any reflog entry of its local ref indicating a\n-\t\t * possible update since checkout; reject the push.\n+\t\t * possible update since checkout, then remember the\n+\t\t * rejection in case force push is needed.\n \t\t */\n \t\tif (ref->expect_old_sha1) {\n \t\t\tif (!oideq(&ref->old_oid, &ref->old_oid_expect))\n \t\t\t\treject_reason = REF_STATUS_REJECT_STALE;\n \t\t\telse if (ref->check_reachable && ref->unreachable)\n-\t\t\t\treject_reason =\n+\t\t\t\tneeds_force_reject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_REMOTE_UPDATED;\n \t\t\telse if (ref->check_reachable && ref->unverifiable)\n-\t\t\t\treject_reason =\n+\t\t\t\tneeds_force_reject_reason =\n \t\t\t\t\tREF_STATUS_REJECT_UNVERIFIABLE;\n \t\t\telse\n \t\t\t\t/*\n@@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,\n \t\t\t\treject_reason = REF_STATUS_REJECT_NONFASTFORWARD;\n \t\t}\n \n+\t\t/*\n+\t\t * If fast-forward rules rejected the push and we were\n+\t\t * asked to verify the reflog but were unable to, then\n+\t\t * reflog verification is the right reject_reason.\n+\t\t */\n+\t\tif (needs_force_reject_reason && reject_reason)\n+\t\t\treject_reason = needs_force_reject_reason;\n+\n \t\t/*\n \t\t * \"--force\" will defeat any rejection implemented\n \t\t * by the rules above.\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex 38576917e4..53e241c5b1 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -421,6 +421,33 @@ test_expect_success '\"--force-if-includes\" should allow forced update from HEAD'\n \t)\n '\n \n+test_expect_success '\"--force-if-includes\" should allow fast-forward push without local reflog' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch main &&\n+\t\tgit reset --hard origin/main &&\n+\t\ttest_commit I &&\n+\t\tgit reflog expire --expire=all --all &&\n+\t\tgit push --force-with-lease --force-if-includes origin main\n+\t)\n+'\n+\n+test_expect_success '\"--force-if-includes\" should allow fast-forward push from tag' '\n+\tsetup_src_dup_dst &&\n+\ttest_when_finished \"rm -fr dst src dup\" &&\n+\t(\n+\t\tcd src &&\n+\t\tgit fetch &&\n+\t\tgit switch -c newbranch origin/main &&\n+\t\ttest_commit I &&\n+\t\tgit tag T &&\n+\t\tgit push --force-with-lease --force-if-includes origin T:main\n+\t)\n+'\n+\n test_expect_success '\"--force-if-includes\" should reject forced update from differently named branches when local lacks remote ref' '\n \tsetup_src_dup_dst &&\n \ttest_when_finished \"rm -fr dst src dup\" &&\n-- \n2.47.3\n\n"},{"id":"554225","messageId":"asQV7QpGglThldfD@localhost.localdomain","threadId":"66273","inReplyTo":"20260917224351.57171-1-tyler@tylercipriani.com","subject":"Re: [PATCH v6 0/3] push: check pushed ref for --force-if-includes","fromName":"Tyler Cipriani","fromEmail":"tyler@tylercipriani.com","sentAt":"2026-10-05T21:26:05Z","receivedAt":"2026-10-05T21:26:05Z","isPatch":true,"body":"Adding Patrick to CC, like I should've from v4 onwards. Whoops!\n\nPatrick: to address your review, since v4, I no longer special-case HEAD\n(as in v3), and, instead, resolve any passed source ref. Now, if the\nsource ref resolves to a branch, we check that branch's reflog for the\nremote tip. But for any other source (e.g., tag, oid, detached HEAD), I\nreject the push as unverifiable. I'd value your opinion on whether that\nmatches up with what you meant.\n\nI'm rejecting anything other than a branch reflog as \"unverifiable\" as\nother reflogs fail to record the integration info we need for\n--force-if-includes. HEAD's reflog spans all branches (rejected in the\nOG review, c. 2020), tag reflogs (when they exist) record where the tag\npointed. And while a source tag/oid may be the same oid as the tip of a\nbranch, using that to map a tag/oid to a branch seems specious: many\nbranches could point to the same commit with no way to say which\nbranch's reflog to check.\n\nBen and I have talked a bit about the consequences of rejecting\nnon-branch pushes with --force-if-includes, viz: it breaks workflows\nthat give the appearance of working today. For example, pushing\n<tag>:hotfix is allowed today (if you have a local \"hotfix\" branch whose\nreflog looks right), but --force-if-includes has never checked anything\nabout the tag. 3/3 lets fast-forward, non-branch pushes through; 2/3's\nadvice points to --force-with-lease=<ref>:<expect> for the rest.\n\nVery interested in others' opinions about this tradeoff.\n\nNote: Junio flagged a trivial textual conflict in t5533 with\nas/push-force-if-includes-no-reflog: both topics add tests after the\nsame existing test.\n\nThere's a small conflict against the tip of maint now, too. a85a43c480\n(push: suggest <remote> <branch> for a slash slip, 2026-06-27) adds some\nadvice that sorts alphabetically after my 2/3. Happy to send a rebased\nv7 if that's helpful.\n\nThanks.\n\n"}]}