From c9d6141b2aafe202c5040ee3309cabeeb6e30487 Mon Sep 17 00:00:00 2001 From: Tyler Cipriani Date: Tue, 15 Sep 2026 17:33:03 -0600 Subject: [PATCH 1/3] push: check pushed ref for --force-if-includes "--force-if-includes" ensures, "tip of the remote-tracking ref is reachable from one of the 'reflog' entries of the local branch." But check_if_includes_upstream() uses the local per-branch reflog based on the destination branch rather than the branch being pushed; using ref->name vs. ref->peer_ref->name. For example, this command looks at the reflog for main vs. src, even though src is being pushed: git push --force-if-includes --force-with-lease origin src:main This can cause confusing rejections or unintended data loss. False rejections: when src is up-to-date with the tip of origin's main, but main is out-of-date or nonexistent, then the force-if-includes check will fail, telling users the remote ref has been updated since the last checkout. Data loss: when src is an orphan/out-dated branch, but main is up-to-date, then the force-if-includes check will allow the push, clobbering the remote main. Instead, use ref->peer_ref to locate a branch with a reflog. But if ref does not resolve to a branch (e.g., a detached HEAD, a tag, an oid), then we reject the push. The alternative would be to use HEAD's reflog, which is too broad to tell us if the history being pushed includes the tip of the remote. We need a per-branch reflog, which means that pushes of a ref that do not resolve to a branch are rejected. Rejecting the push of a ref like a detached HEAD already happens today (if the same-named local branch lacks the remote tip); now the detached HEAD and other non-branch pushes are explicitly rejected. Allow deletions, e.g.: git push --force-if-includes --force-with-lease origin :main A deletion has no source ref, so no branch reflog can be checked. Existing tests already enforce that deletions should work with force-if-includes. ref->deletion is set after apply_push_cas (which triggers check_if_includes_upstream). The ref->peer_ref name is "(delete)". Instead check with is_null_oid to detect and allow deletion. The early return when peer_ref is missing in check_if_includes_upstream is necessary because apply_push_cas walks every advertised ref whenever use_tracking_for_rest is set (i.e., a bare --force-with-lease), so check_if_includes upstream is called for for refs that are not part of the push. Remove unnecessary check for empty return from get_local_ref, since it never returns NULL for a non-empty name. Reported-by: Stefan Haller Reported-by: D. Ben Knoble Signed-off-by: Tyler Cipriani Signed-off-by: Junio C Hamano --- remote.c | 26 +++++++++++++-- t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/remote.c b/remote.c index 00723b385e..887c7ec00c 100644 --- a/remote.c +++ b/remote.c @@ -2806,10 +2806,32 @@ cleanup_return: */ static void check_if_includes_upstream(struct ref *remote) { - struct ref *local = get_local_ref(remote->name); - if (!local) + struct ref *local; + const char *name; + + /* ref without peer_ref will not be pushed */ + if (!remote->peer_ref) return; + /* A deletion has no local history to check against. */ + if (is_null_oid(&remote->peer_ref->new_oid)) + return; + + name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository), + remote->peer_ref->name, + RESOLVE_REF_READING, NULL, NULL); + + /* + * if we resolve the ref to anything other than a branch, + * then there is no reliable reflog to check + */ + if (!name || !starts_with(name, "refs/heads/")) { + remote->unreachable = 1; + return; + } + + local = get_local_ref(name); + if (is_reachable_in_reflog(local->name, remote) <= 0) remote->unreachable = 1; free_one_ref(local); diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh index cba26a872d..265be6a84c 100755 --- a/t/t5533-push-cas.sh +++ b/t/t5533-push-cas.sh @@ -396,4 +396,85 @@ test_expect_success '"--force-if-includes" should allow deletes' ' ) ' +test_expect_success '"--force-if-includes" should allow forced update when using differently named branches' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch -c newbranch origin/main && + git rebase HEAD --onto HEAD^ && + git push --force-if-includes --force-with-lease origin newbranch:main + ) +' + +test_expect_success '"--force-if-includes" should allow forced update from HEAD' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch -c newbranch origin/main && + git rebase HEAD --onto HEAD^ && + git push --force-if-includes --force-with-lease origin HEAD:main + ) +' + +test_expect_success '"--force-if-includes" should reject forced update from differently named branches when local lacks remote ref' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch main && + git reset --hard origin/main && + git switch --orphan orphan && + test_commit I && + test_must_fail git push --force-with-lease --force-if-includes origin orphan:main + ) +' + +test_expect_success '"--force-if-includes" should reject forced update from HEAD when it lacks remote ref' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch main && + git reset --hard origin/main && + git switch --orphan orphan && + test_commit I && + test_must_fail git push --force-with-lease --force-if-includes origin HEAD:main + ) +' + +test_expect_success '"--force-if-includes" should reject forced update from detached HEAD' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch main && + git reset --hard origin/main && + git switch -c newbranch origin/main && + git checkout HEAD^ && + test_must_fail git push --force-if-includes --force-with-lease origin HEAD:main + ) +' + +test_expect_success '"--force-if-includes" should reject forced update from tag' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch main && + git reset --hard origin/main && + git switch -c newbranch origin/main && + git checkout HEAD^ && + git tag stable && + test_must_fail git push --force-if-includes --force-with-lease origin stable:main + ) +' + test_done From ce389b95560842bd8970a7f49b4a42bf6d6185bb Mon Sep 17 00:00:00 2001 From: Tyler Cipriani Date: Tue, 15 Sep 2026 17:33:04 -0600 Subject: [PATCH 2/3] push: fix --force-if-includes non-branch advice When a --force-if-includes push is rejected due lacking reflog to consult, the advice is misleading: ! [rejected] HEAD -> main (remote ref updated since checkout) error: failed to push some refs to '' hint: Updates were rejected because the tip of the remote-tracking hint: branch has been updated since the last checkout. If you want hint: to integrate the remote changes, use 'git pull' before hint: pushing again. See the 'Note about fast-forwards' in 'git hint: push --help' for details. But a `git pull` will not fix this rejection. What is required is either - Specify the expected remote tip with --force-with-lease=: - Ignore the error with --no-force-if-includes Add ref->unverifiable to differentiate pushing something without a reflog to consult vs. a remote update rejection. Ensure tests check the rejection message. Reported-by: D. Ben Knoble Signed-off-by: Tyler Cipriani Signed-off-by: Junio C Hamano --- Documentation/config/advice.adoc | 4 ++++ advice.c | 1 + advice.h | 1 + builtin/push.c | 17 +++++++++++++++++ builtin/send-pack.c | 5 +++++ remote.c | 5 ++++- remote.h | 10 +++++++--- send-pack.c | 1 + t/t5533-push-cas.sh | 11 ++++++++--- transport-helper.c | 5 +++++ transport.c | 8 ++++++++ transport.h | 1 + 12 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc index 257db58918..8d258980ff 100644 --- a/Documentation/config/advice.adoc +++ b/Documentation/config/advice.adoc @@ -90,6 +90,10 @@ all advice messages. Shown when linkgit:git-push[1] rejects a forced update of a branch when its remote-tracking ref has updates that we do not have locally. + pushRefUnverifiable:: + Shown when linkgit:git-push[1] rejects a forced update of + a branch when we are unable to verify the remote-tracking + ref is integrated locally. pushUnqualifiedRefname:: Shown when linkgit:git-push[1] gives up trying to guess based on the source and destination refs what diff --git a/advice.c b/advice.c index 0018501b7b..08842deb66 100644 --- a/advice.c +++ b/advice.c @@ -69,6 +69,7 @@ static struct { [ADVICE_PUSH_NON_FF_CURRENT] = { "pushNonFFCurrent" }, [ADVICE_PUSH_NON_FF_MATCHING] = { "pushNonFFMatching" }, [ADVICE_PUSH_REF_NEEDS_UPDATE] = { "pushRefNeedsUpdate" }, + [ADVICE_PUSH_REF_UNVERIFIABLE] = { "pushRefUnverifiable" }, [ADVICE_PUSH_UNQUALIFIED_REF_NAME] = { "pushUnqualifiedRefName" }, [ADVICE_PUSH_UPDATE_REJECTED] = { "pushUpdateRejected" }, [ADVICE_PUSH_UPDATE_REJECTED_ALIAS] = { "pushNonFastForward" }, /* backwards compatibility */ diff --git a/advice.h b/advice.h index 8def280688..189eadc089 100644 --- a/advice.h +++ b/advice.h @@ -36,6 +36,7 @@ enum advice_type { ADVICE_PUSH_NON_FF_CURRENT, ADVICE_PUSH_NON_FF_MATCHING, ADVICE_PUSH_REF_NEEDS_UPDATE, + ADVICE_PUSH_REF_UNVERIFIABLE, ADVICE_PUSH_UNQUALIFIED_REF_NAME, ADVICE_PUSH_UPDATE_REJECTED, ADVICE_PUSH_UPDATE_REJECTED_ALIAS, diff --git a/builtin/push.c b/builtin/push.c index 6021b71d66..679d9cee83 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -319,6 +319,13 @@ static const char message_advice_ref_needs_update[] = "remote changes, use 'git pull' before pushing again.\n" "See the 'Note about fast-forwards' in 'git push --help' for details."); +static const char message_advice_ref_unverifiable[] = + N_("Updates were rejected because what you are pushing is not a branch,\n" + "so there is no reflog to check against the tip of the remote-tracking\n" + "branch. If you want to push anyway, specify the expected value with\n" + "'--force-with-lease=:' or use '--no-force-if-includes'\n" + "to skip this check."); + static void advise_pull_before_push(void) { if (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED)) @@ -361,6 +368,14 @@ static void advise_ref_needs_update(void) advise(_(message_advice_ref_needs_update)); } +static void advise_ref_unverifiable(void) +{ + if (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || + !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED)) + return; + advise(_(message_advice_ref_unverifiable)); +} + static int push_with_options(struct transport *transport, struct refspec *rs, int flags) { @@ -412,6 +427,8 @@ static int push_with_options(struct transport *transport, struct refspec *rs, advise_ref_needs_force(); } else if (reject_reasons & REJECT_REF_NEEDS_UPDATE) { advise_ref_needs_update(); + } else if (reject_reasons & REJECT_REF_UNVERIFIABLE) { + advise_ref_unverifiable(); } return 1; diff --git a/builtin/send-pack.c b/builtin/send-pack.c index 1412b49bc8..07accb6e6b 100644 --- a/builtin/send-pack.c +++ b/builtin/send-pack.c @@ -76,6 +76,11 @@ static void print_helper_status(struct ref *ref) msg = "remote ref updated since checkout"; break; + case REF_STATUS_REJECT_UNVERIFIABLE: + res = "error"; + msg = "remote ref unverifiable"; + break; + case REF_STATUS_REJECT_ALREADY_EXISTS: res = "error"; msg = "already exists"; diff --git a/remote.c b/remote.c index 887c7ec00c..b7b5ac0d28 100644 --- a/remote.c +++ b/remote.c @@ -1701,6 +1701,9 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror, else if (ref->check_reachable && ref->unreachable) reject_reason = REF_STATUS_REJECT_REMOTE_UPDATED; + else if (ref->check_reachable && ref->unverifiable) + reject_reason = + REF_STATUS_REJECT_UNVERIFIABLE; else /* * If the ref isn't stale, and is reachable @@ -2826,7 +2829,7 @@ static void check_if_includes_upstream(struct ref *remote) * then there is no reliable reflog to check */ if (!name || !starts_with(name, "refs/heads/")) { - remote->unreachable = 1; + remote->unverifiable = 1; return; } diff --git a/remote.h b/remote.h index 54b17e4b02..8e2d56c2c2 100644 --- a/remote.h +++ b/remote.h @@ -169,10 +169,13 @@ struct ref { /* Need to check if local reflog reaches the remote tip. */ check_reachable:1, /* - * Store the result of the check enabled by "check_reachable"; - * implies the local reflog does not reach the remote tip. + * Store the result of the check enabled by "check_reachable". + * "unreachable" implies the local reflog does not reach the remote + * tip. "unverifiable" implies no local branch reflog to check; i.e., + * detached HEAD. */ - unreachable:1; + unreachable:1, + unverifiable:1; enum { REF_NOT_MATCHED = 0, /* initial value */ @@ -203,6 +206,7 @@ struct ref { REF_STATUS_REJECT_STALE, REF_STATUS_REJECT_SHALLOW, REF_STATUS_REJECT_REMOTE_UPDATED, + REF_STATUS_REJECT_UNVERIFIABLE, REF_STATUS_UPTODATE, REF_STATUS_REMOTE_REJECT, REF_STATUS_EXPECTING_REPORT, diff --git a/send-pack.c b/send-pack.c index 3bb5afc687..6b78470f37 100644 --- a/send-pack.c +++ b/send-pack.c @@ -322,6 +322,7 @@ static int check_to_send_update(const struct ref *ref, const struct send_pack_ar case REF_STATUS_REJECT_NEEDS_FORCE: case REF_STATUS_REJECT_STALE: case REF_STATUS_REJECT_REMOTE_UPDATED: + case REF_STATUS_REJECT_UNVERIFIABLE: case REF_STATUS_REJECT_NODELETE: return CHECK_REF_STATUS_REJECTED; case REF_STATUS_UPTODATE: diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh index 265be6a84c..38576917e4 100755 --- a/t/t5533-push-cas.sh +++ b/t/t5533-push-cas.sh @@ -311,7 +311,8 @@ test_expect_success 'background updates to remote can be mitigated with "--force git switch main && test_commit J && git fetch --all && - test_must_fail git push --force-with-lease --force-if-includes --all + test_must_fail git push --force-with-lease --force-if-includes --all 2>err && + test_grep "remote ref updated since checkout" err ) && git ls-remote dst refs/heads/main >actual.main && git ls-remote dst refs/heads/branch >actual.branch && @@ -458,7 +459,9 @@ test_expect_success '"--force-if-includes" should reject forced update from deta git reset --hard origin/main && git switch -c newbranch origin/main && git checkout HEAD^ && - test_must_fail git push --force-if-includes --force-with-lease origin HEAD:main + test_must_fail git push --force-if-includes --force-with-lease origin HEAD:main 2>err && + test_grep "remote ref unverifiable" err && + test_grep "no-force-if-includes" err ) ' @@ -473,7 +476,9 @@ test_expect_success '"--force-if-includes" should reject forced update from tag' git switch -c newbranch origin/main && git checkout HEAD^ && git tag stable && - test_must_fail git push --force-if-includes --force-with-lease origin stable:main + test_must_fail git push --force-if-includes --force-with-lease origin stable:main 2>err && + test_grep "remote ref unverifiable" err && + test_grep "no-force-if-includes" err ) ' diff --git a/transport-helper.c b/transport-helper.c index 80f90eb7ba..1763570352 100644 --- a/transport-helper.c +++ b/transport-helper.c @@ -893,6 +893,10 @@ static int push_update_ref_status(struct strbuf *buf, status = REF_STATUS_REJECT_REMOTE_UPDATED; FREE_AND_NULL(msg); } + else if (!strcmp(msg, "remote ref unverifiable")) { + status = REF_STATUS_REJECT_UNVERIFIABLE; + FREE_AND_NULL(msg); + } else if (!strcmp(msg, "forced update")) { forced = 1; FREE_AND_NULL(msg); @@ -1046,6 +1050,7 @@ static int push_refs_with_push(struct transport *transport, case REF_STATUS_REJECT_STALE: case REF_STATUS_REJECT_ALREADY_EXISTS: case REF_STATUS_REJECT_REMOTE_UPDATED: + case REF_STATUS_REJECT_UNVERIFIABLE: if (atomic) { reject_atomic_push(remote_refs, mirror); string_list_clear(&cas_options, 0); diff --git a/transport.c b/transport.c index 0f5ec30247..3d60d6de54 100644 --- a/transport.c +++ b/transport.c @@ -779,6 +779,11 @@ static int print_one_push_report(struct ref *ref, const char *dest, int count, "remote ref updated since checkout", report, porcelain, summary_width); break; + case REF_STATUS_REJECT_UNVERIFIABLE: + print_ref_status('!', "[rejected]", ref, ref->peer_ref, + "remote ref unverifiable", + report, porcelain, summary_width); + break; case REF_STATUS_REJECT_SHALLOW: print_ref_status('!', "[rejected]", ref, ref->peer_ref, "new shallow roots not allowed", @@ -893,6 +898,8 @@ void transport_print_push_status(const char *dest, struct ref *refs, *reject_reasons |= REJECT_NEEDS_FORCE; } else if (ref->status == REF_STATUS_REJECT_REMOTE_UPDATED) { *reject_reasons |= REJECT_REF_NEEDS_UPDATE; + } else if (ref->status == REF_STATUS_REJECT_UNVERIFIABLE) { + *reject_reasons |= REJECT_REF_UNVERIFIABLE; } } free(head); @@ -1348,6 +1355,7 @@ static int pre_push_hook_feed_stdin(int hook_stdin_fd, void *pp_cb UNUSED, void switch (r->status) { case REF_STATUS_REJECT_NONFASTFORWARD: case REF_STATUS_REJECT_REMOTE_UPDATED: + case REF_STATUS_REJECT_UNVERIFIABLE: case REF_STATUS_REJECT_STALE: case REF_STATUS_UPTODATE: return 0; /* skip refs which won't be pushed */ diff --git a/transport.h b/transport.h index 7e5867cffa..eaa3b616ee 100644 --- a/transport.h +++ b/transport.h @@ -256,6 +256,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity, #define REJECT_FETCH_FIRST 0x08 #define REJECT_NEEDS_FORCE 0x10 #define REJECT_REF_NEEDS_UPDATE 0x20 +#define REJECT_REF_UNVERIFIABLE 0x40 int transport_push(struct repository *repo, struct transport *connection, From 3d61020d7dfb6864214c19bc8677828ac7835179 Mon Sep 17 00:00:00 2001 From: Tyler Cipriani Date: Tue, 15 Sep 2026 17:33:05 -0600 Subject: [PATCH 3/3] push: --force-if-includes should allow fast-forward In set_ref_status_for_push, we verify --force-if-includes's reflog reachability checks before fast-forward rules. As a result, valid fast-forward pushes may be rejected when a force push is unneeded; like when the reflog is expired: git clone repo.git repo git commit --allow-empty -m 1 git reflog expire --expire=all --all git push --force-with-lease --force-if-includes origin main ! [rejected] main -> main (remote ref updated since checkout) Rejecting fast-forwards is a mismatch with the --force-if-includes documentation "Force an update only if the tip of the remote-tracking ref has been integrated locally." Instead, defer check for --force-if-includes until after determining if a push force is needed. Opted to create a deferred_reject_reason in set_ref_status_for_push rather than move the computation of reachability or verifiability to winnow scope of changes in this patch. Lazily checking for reachability or verifiability is a valid followup. Signed-off-by: Tyler Cipriani Signed-off-by: Junio C Hamano --- remote.c | 16 +++++++++++++--- t/t5533-push-cas.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/remote.c b/remote.c index b7b5ac0d28..db0b50b030 100644 --- a/remote.c +++ b/remote.c @@ -1669,6 +1669,7 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror, for (ref = remote_refs; ref; ref = ref->next) { int force_ref_update = ref->force || force_update; int reject_reason = 0; + int deferred_reject_reason = 0; if (ref->peer_ref) oidcpy(&ref->new_oid, &ref->peer_ref->new_oid); @@ -1693,16 +1694,17 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror, * * If the tip of the remote-tracking ref is unreachable * from any reflog entry of its local ref indicating a - * possible update since checkout; reject the push. + * possible update since checkout, then remember the + * rejection in case the push is non-fast-forward. */ if (ref->expect_old_sha1) { if (!oideq(&ref->old_oid, &ref->old_oid_expect)) reject_reason = REF_STATUS_REJECT_STALE; else if (ref->check_reachable && ref->unreachable) - reject_reason = + deferred_reject_reason = REF_STATUS_REJECT_REMOTE_UPDATED; else if (ref->check_reachable && ref->unverifiable) - reject_reason = + deferred_reject_reason = REF_STATUS_REJECT_UNVERIFIABLE; else /* @@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror, reject_reason = REF_STATUS_REJECT_NONFASTFORWARD; } + /* + * If push is non-fast-forward and we were asked to + * verify the reflog but were unable to, then reflog + * verification is the right reject_reason. + */ + if (deferred_reject_reason && reject_reason) + reject_reason = deferred_reject_reason; + /* * "--force" will defeat any rejection implemented * by the rules above. diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh index 38576917e4..53e241c5b1 100755 --- a/t/t5533-push-cas.sh +++ b/t/t5533-push-cas.sh @@ -421,6 +421,33 @@ test_expect_success '"--force-if-includes" should allow forced update from HEAD' ) ' +test_expect_success '"--force-if-includes" should allow fast-forward push without local reflog' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch main && + git reset --hard origin/main && + test_commit I && + git reflog expire --expire=all --all && + git push --force-with-lease --force-if-includes origin main + ) +' + +test_expect_success '"--force-if-includes" should allow fast-forward push from tag' ' + setup_src_dup_dst && + test_when_finished "rm -fr dst src dup" && + ( + cd src && + git fetch && + git switch -c newbranch origin/main && + test_commit I && + git tag T && + git push --force-with-lease --force-if-includes origin T:main + ) +' + test_expect_success '"--force-if-includes" should reject forced update from differently named branches when local lacks remote ref' ' setup_src_dup_dst && test_when_finished "rm -fr dst src dup" &&