Volume XXII, number 279Tuesday, October 6, 2026Latest message 1 hour ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patch, 2 partspush: fix --force-if-includes consulting wrong ref

40 messages between Sep 4, 2026 and Oct 5, 2026, from Tyler Cipriani, Ben Knoble, D. Ben Knoble, Junio C Hamano, Patrick Steinhardt.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Tyler CiprianiSep 4, 2026, 21:01 UTC on lore

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
               false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow forced-update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch lacking
  branch tip
- reject same as above using HEAD
- reject detached HEAD

Open question: the detached HEAD case. I opted to reject, since it seems like it might be surprising to allow in the case where you were just on a branch without the the tip of a remote ref, removed the last commit with git checkout HEAD^ and pushed with --force-if-includes and it allowed a destructive push. I made a separate patch showing different advice for that case (since a git pull won't help).

Based on maint since this is a bugfix. Happy to split patches any way that's helpful.

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>

Tyler Cipriani (2):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes detached HEAD advice
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++
 builtin/send-pack.c              |  5 +++
 remote.c                         | 27 +++++++++++-
 remote.h                         | 10 +++--
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-
 transport-helper.c               |  5 +++
 transport.c                      |  8 ++++
 transport.h                      |  1 +
 12 files changed, 143 insertions(+), 5 deletions(-)
base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc
-- 
2.47.3
Tyler CiprianiSep 4, 2026, 21:01 UTC in reply to Tyler Cipriani on lore

[PATCH 1/2] 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.

This can cause confusing rejections or unintended data loss.
Using a command like:
    git push --force-if-includes --force-with-lease origin src:main

False rejections: when src is an up-to-date branch, but main is out-of-date or nonexistent, then the 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 if-includes check will allow the push, clobbering the remote main.

Find local reflog using ref->peer_ref. When using a refspec like HEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog. In a detached HEAD state, the reflog cannot tell us if the history being pushed includes the tip of the remote, so the push is rejected.

Skip deletions:
    git push --force-if-includes --force-with-lease origin :main

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.

Reported-by: Stefan Haller <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 24 ++++++++++++++++-
 t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+), 1 deletion(-)
Show changes to 2 files +88 −1

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..326af76eeb 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 static void check_if_includes_upstream(struct ref *remote)
 {
-	struct ref *local = get_local_ref(remote->name);
+	struct ref *local;
+	const char *name;
+	int flag;
+
+	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 = remote->peer_ref->name;
+	if (!strcmp(name, "HEAD")) {
+		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+					       "HEAD", 0, NULL, &flag);
+		if (!name || !(flag & REF_ISSYMREF)) {
+			/* detached HEAD: no per-branch reflog to consult */
+			remote->unreachable = 1;
+			return;
+		}
+	}
+
+	local = get_local_ref(name);
 	if (!local)
 		return;
 
diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
index cba26a872d..0c02151747 100755
--- a/t/t5533-push-cas.sh
+++ b/t/t5533-push-cas.sh
@@ -396,4 +396,69 @@ 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_done
-- 
2.47.3
Tyler CiprianiSep 4, 2026, 21:01 UTC in reply to Tyler Cipriani on lore

[PATCH 2/2] push: fix --force-if-includes detached HEAD advice

When a --force-if-includes push is rejected due to a detached HEAD state where there is no per-branch reflog to consult, the advice is misleading:

     ! [rejected] HEAD -> main (remote ref updated since checkout)
    error: failed to push some refs to '<remote>'
    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=<ref>:<expect>
- Ignore the error with --no-force-if-includes

Add ref->unverifiable to differentiate between a detached HEAD rejection vs. a remote update rejection.

Ensure tests check the rejection message.
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 Documentation/config/advice.adoc |  4 ++++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++++++++++
 builtin/send-pack.c              |  5 +++++
 remote.c                         |  5 ++++-
 remote.h                         | 10 +++++++---
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              |  7 +++++--
 transport-helper.c               |  5 +++++
 transport.c                      |  8 ++++++++
 transport.h                      |  1 +
 12 files changed, 57 insertions(+), 6 deletions(-)
Show changes to 12 files +57 −6

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..a0eff8bbd6 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 available 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..9676c6241f 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -319,6 +319,12 @@ 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 the tip of the remote-tracking branch\n"
+	   "cannot be checked against a detached HEAD. If you want to push anyway,\n"
+	   "specify the expected value with '--force-with-lease=<ref>:<expect>'\n"
+	   "or use '--no-force-if-includes' 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 +367,13 @@ 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 +425,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 326af76eeb..72bfc4dbc4 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
@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)
 					       "HEAD", 0, NULL, &flag);
 		if (!name || !(flag & REF_ISSYMREF)) {
 			/* detached HEAD: no per-branch reflog to consult */
-			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 0c02151747..fe6af3f41c 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 &&
@@ -457,7 +458,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
 	)
 '
 
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,
-- 
2.47.3
Ben KnobleSep 5, 2026, 18:57 UTC in reply to Tyler Cipriani on lore

Re: [PATCH 1/2] push: check pushed ref for --force-if-includes

Show 22 quoted lines
> Le 4 sept. 2026 à 17:01, Tyler Cipriani <tyler@tylercipriani.com> a écrit :
> 
> "--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.
> 
> This can cause confusing rejections or unintended data loss.
> 
> Using a command like:
> 
>  git push --force-if-includes --force-with-lease origin src:main
> 
> False rejections: when src is an up-to-date branch, but main is
> out-of-date or nonexistent, then the 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 if-includes check will allow the push, clobbering
> the remote main.

Hm. This case *could* be by design, to rewind and potentially modify a remote branch, discarding new work I’ve already checked.

But the includes check is about reminding to do such a check. So failing and requiring me to bypass the check seems ok.

> Find local reflog using ref->peer_ref. When using a refspec like
> HEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog.
> In a detached HEAD state, the reflog cannot tell us if the history
> being pushed includes the tip of the remote, so the push is rejected.

This seems to be what I reported in the mail your cover letter cites. So, am I reading correctly that this is no change from current behavior?

…ah, patch 2 addresses that specifically. Which, I now remember you said in the cover as well. Oops.

It *could* be worth clarifying in the proposed log message that we are only preserving behavior here, but that’s a very small nit.
Show 127 quoted lines
> Skip deletions:
> 
>  git push --force-if-includes --force-with-lease origin :main
> 
> 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.
> 
> Reported-by: Stefan Haller <lists@haller-berlin.de>
> Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
> Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
> ---
> remote.c            | 24 ++++++++++++++++-
> t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++
> 2 files changed, 88 insertions(+), 1 deletion(-)
> 
> diff --git a/remote.c b/remote.c
> index 00723b385e..326af76eeb 100644
> --- a/remote.c
> +++ b/remote.c
> @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
> */
> static void check_if_includes_upstream(struct ref *remote)
> {
> -    struct ref *local = get_local_ref(remote->name);
> +    struct ref *local;
> +    const char *name;
> +    int flag;
> +
> +    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 = remote->peer_ref->name;
> +    if (!strcmp(name, "HEAD")) {
> +        name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
> +                           "HEAD", 0, NULL, &flag);
> +        if (!name || !(flag & REF_ISSYMREF)) {
> +            /* detached HEAD: no per-branch reflog to consult */
> +            remote->unreachable = 1;
> +            return;
> +        }
> +    }
> +
> +    local = get_local_ref(name);
>  if (!local)
>      return;
> 
> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
> index cba26a872d..0c02151747 100755
> --- a/t/t5533-push-cas.sh
> +++ b/t/t5533-push-cas.sh
> @@ -396,4 +396,69 @@ 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_done
> --
> 2.47.3
Ben KnobleSep 5, 2026, 18:59 UTC in reply to Tyler Cipriani on lore

Re: [PATCH 0/2] push: fix --force-if-includes consulting wrong ref

Show 11 quoted lines
> Le 4 sept. 2026 à 17:01, Tyler Cipriani <tyler@tylercipriani.com> a écrit :
> 
> --force-if-includes has been checking the reflog of the local branch named
> after the destination branch regardless of what's being pushed. This can cause
> false rejections or unintended data loss.
> 
> False rejection has been reported twice that I could find:
> 
> - 2023-07-26 - Stefan Haller reported local branch with a different name
>              false rejection[0]
> - 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

Aha. I’d nearly forgotten that mail, and have since adjusted to some intuition of when to force-if-includes.

I’d be grateful to not need such potentially-buggy intuition :)
Show 31 quoted lines
> The same root cause can result in data loss: when a same-name local branch
> contains the remote tip but you --force-if-includes push an unrelated branch,
> clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases
> fail against maint, but pass with patches applied.
> 
> Existing tests covered refspecs with different names for --force-with-lease,
> but missed --force-if-includes. New patches cover:
> 
> - allow forced-update using refspec with different-named local branch
> - allow same as above, but with HEAD
> - reject force-update using refspec with different-named local branch lacking
> branch tip
> - reject same as above using HEAD
> - reject detached HEAD
> 
> Open question: the detached HEAD case. I opted to reject, since it seems like
> it might be surprising to allow in the case where you were just on a branch
> without the the tip of a remote ref, removed the last commit with git checkout
> HEAD^ and pushed with --force-if-includes and it allowed a destructive push.
> I made a separate patch showing different advice for that case (since a
> git pull won't help).
> 
> Based on maint since this is a bugfix. Happy to split patches any way
> that's helpful.
> 
> [0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de>
> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com>
> 
> Tyler Cipriani (2):
> push: check pushed ref for --force-if-includes
> push: fix --force-if-includes detached HEAD advice

Thanks for the advice changes! One small nit on the first patch you can ignore if you choose.

At first I hoped we might be able to stop rejecting detached HEAD pushes, but some further thought begs the question: what reflog would we use? HEAD’s is too broad :)

So this may be all we can do for now.
At least I can replace my intuition with reading the error message again. 
Tyler CiprianiSep 6, 2026, 20:24 UTC in reply to Ben Knoble on lore

Re: [PATCH 0/2] push: fix --force-if-includes consulting wrong ref

On Sat, Sep 5, 2026 at 12:59 PM Ben Knoble <ben.knoble@gmail.com> wrote:
> Thanks for the advice changes! One small nit on the first
> patch you can ignore if you choose.

Good call on updating the log message for PATCH 1/2. I'll note that detached HEAD is already rejected in v2.

Show 6 quoted lines
> At first I hoped we might be able to stop rejecting detached
> HEAD pushes, but some further thought begs the question:
> what reflog would we use?
> HEAD’s is too broad :)
>
> So this may be all we can do for now.

It looks like that's the conclusion they reached on the original patchset, too, based on my re-reading of the thread[0]. HEAD's reflog is too broad for the --force-if-includes check (with the acknowledged downside being that --force-if-includes isn't useful for the detached HEAD case.)

[0]: <https://lore.kernel.org/git/xmqqsgbdk69b.fsf@gitster.c.googlers.com/>
> At least I can replace my intuition with reading the error message again.
:)
Thank you for the review!
Tyler CiprianiSep 8, 2026, 22:20 UTC in reply to Tyler Cipriani on lore

[PATCH v2 0/2] push: fix --force-if-includes consulting wrong ref

Changes since v1:
- Clarify in log message 1/2 that --force-if-includes will reject a
  detached HEAD today (when the same-named local branch lacks the remote
  tip). And note that this change makes it explicit to always reject
  the detached HEAD case.

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
               false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow forced-update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch lacking
  branch tip
- reject same as above using HEAD
- reject detached HEAD

Resolved question: the detached HEAD case; HEAD's reflog was considered and rejected as too broad for purpose in the original review. cf. [2]

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com> [2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>

Tyler Cipriani (2):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes detached HEAD advice
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++
 builtin/send-pack.c              |  5 +++
 remote.c                         | 27 +++++++++++-
 remote.h                         | 10 +++--
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-
 transport-helper.c               |  5 +++
 transport.c                      |  8 ++++
 transport.h                      |  1 +
 12 files changed, 143 insertions(+), 5 deletions(-)
Range-diff against v1:
1:  5e866b883e ! 1:  da27c421ed push: check pushed ref for --force-if-includes
    @@ Commit message
         the remote main.
     
         Find local reflog using ref->peer_ref. When using a refspec like
    -    HEAD:refs/heads/main, we resolve HEAD to a branch and use that reflog.
    -    In a detached HEAD state, the reflog cannot tell us if the history
    -    being pushed includes the tip of the remote, so the push is rejected.
    +    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
    +    branch's reflog.
    +
    +    But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we
    +    reject the push. HEAD's reflog is too broad to tell us if the history
    +    being pushed includes the tip of the remote. Rejecting a detached HEAD
    +    already happens today (if the same-named local branch lacks the remote
    +    tip); now the detached HEAD state is explicitly rejected.
     
         Skip deletions:
     
2:  4ae40db7fe = 2:  e07d16d53e push: fix --force-if-includes detached HEAD advice
base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc
-- 
2.47.3
Tyler CiprianiSep 8, 2026, 22:20 UTC in reply to Tyler Cipriani on lore

[PATCH v2 1/2] 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.

This can cause confusing rejections or unintended data loss.
Using a command like:
    git push --force-if-includes --force-with-lease origin src:main

False rejections: when src is an up-to-date branch, but main is out-of-date or nonexistent, then the 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 if-includes check will allow the push, clobbering the remote main.

Find local reflog using ref->peer_ref. When using a refspec like HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that branch's reflog.

But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we reject the push. HEAD's reflog is too broad to tell us if the history being pushed includes the tip of the remote. Rejecting a detached HEAD already happens today (if the same-named local branch lacks the remote tip); now the detached HEAD state is explicitly rejected.

Skip deletions:
    git push --force-if-includes --force-with-lease origin :main

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.

Reported-by: Stefan Haller <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 24 ++++++++++++++++-
 t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+), 1 deletion(-)
Show changes to 2 files +88 −1

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..326af76eeb 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 static void check_if_includes_upstream(struct ref *remote)
 {
-	struct ref *local = get_local_ref(remote->name);
+	struct ref *local;
+	const char *name;
+	int flag;
+
+	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 = remote->peer_ref->name;
+	if (!strcmp(name, "HEAD")) {
+		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+					       "HEAD", 0, NULL, &flag);
+		if (!name || !(flag & REF_ISSYMREF)) {
+			/* detached HEAD: no per-branch reflog to consult */
+			remote->unreachable = 1;
+			return;
+		}
+	}
+
+	local = get_local_ref(name);
 	if (!local)
 		return;
 
diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
index cba26a872d..0c02151747 100755
--- a/t/t5533-push-cas.sh
+++ b/t/t5533-push-cas.sh
@@ -396,4 +396,69 @@ 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_done
-- 
2.47.3
Tyler CiprianiSep 8, 2026, 22:20 UTC in reply to Tyler Cipriani on lore

[PATCH v2 2/2] push: fix --force-if-includes detached HEAD advice

When a --force-if-includes push is rejected due to a detached HEAD state where there is no per-branch reflog to consult, the advice is misleading:

     ! [rejected] HEAD -> main (remote ref updated since checkout)
    error: failed to push some refs to '<remote>'
    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=<ref>:<expect>
- Ignore the error with --no-force-if-includes

Add ref->unverifiable to differentiate between a detached HEAD rejection vs. a remote update rejection.

Ensure tests check the rejection message.
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 Documentation/config/advice.adoc |  4 ++++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++++++++++
 builtin/send-pack.c              |  5 +++++
 remote.c                         |  5 ++++-
 remote.h                         | 10 +++++++---
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              |  7 +++++--
 transport-helper.c               |  5 +++++
 transport.c                      |  8 ++++++++
 transport.h                      |  1 +
 12 files changed, 57 insertions(+), 6 deletions(-)
Show changes to 12 files +57 −6

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..a0eff8bbd6 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 available 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..9676c6241f 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -319,6 +319,12 @@ 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 the tip of the remote-tracking branch\n"
+	   "cannot be checked against a detached HEAD. If you want to push anyway,\n"
+	   "specify the expected value with '--force-with-lease=<ref>:<expect>'\n"
+	   "or use '--no-force-if-includes' 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 +367,13 @@ 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 +425,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 326af76eeb..72bfc4dbc4 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
@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)
 					       "HEAD", 0, NULL, &flag);
 		if (!name || !(flag & REF_ISSYMREF)) {
 			/* detached HEAD: no per-branch reflog to consult */
-			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 0c02151747..fe6af3f41c 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 &&
@@ -457,7 +458,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
 	)
 '
 
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,
-- 
2.47.3
D. Ben KnobleSep 9, 2026, 11:59 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v2 0/2] push: fix --force-if-includes consulting wrong ref

On Tue, Sep 8, 2026 at 6:21 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:
Show 7 quoted lines
>
> Changes since v1:
>
> - Clarify in log message 1/2 that --force-if-includes will reject a
>   detached HEAD today (when the same-named local branch lacks the remote
>   tip). And note that this change makes it explicit to always reject
>   the detached HEAD case.
Thanks!
Junio C HamanoSep 10, 2026, 18:43 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v2 1/2] push: check pushed ref for --force-if-includes

Tyler Cipriani <tyler@tylercipriani.com> writes:
> Message-ID: <20260908222056.1150748-2-tyler@tylercipriani.com>
> References: <20260904210122.431757-1-tyler@tylercipriani.com>

This is incorrectly threaded. It is not made as a reply to the cover letter of v2; it is a reply to the cover letter of the initial iteration, and breaks automation.

The same problem exists for [v2 2/2] as well.
Tyler CiprianiSep 10, 2026, 22:08 UTC in reply to Junio C Hamano on lore

Re: [PATCH v2 1/2] push: check pushed ref for --force-if-includes

On Thu, Sep 10, 2026 at 12:43 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 11 quoted lines
>
> Tyler Cipriani <tyler@tylercipriani.com> writes:
>
> > Message-ID: <20260908222056.1150748-2-tyler@tylercipriani.com>
> > References: <20260904210122.431757-1-tyler@tylercipriani.com>
>
> This is incorrectly threaded.  It is not made as a reply to the
> cover letter of v2; it is a reply to the cover letter of the initial
> iteration, and breaks automation.
>
> The same problem exists for [v2 2/2] as well.

Sorry for that. I had --in-reply-to on format-patch vs. send-email. I'll send a v3 with correct shallow threading.

Tyler CiprianiSep 10, 2026, 23:05 UTC in reply to Tyler Cipriani on lore

[PATCH v3 0/2] push: fix --force-if-includes consulting wrong ref

Changes since v2:
- Correct patch threading of 1/2 and 2/2 to reply to cover letter of
  current patchset vs. cover letter of the initial iteration.
Changes since v1:
- Clarify in log message 1/2 that --force-if-includes will reject a
  detached HEAD today (when the same-named local branch lacks the remote
  tip). And note that this change makes it explicit to always reject
  the detached HEAD case.

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
               false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow forced-update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch lacking
  branch tip
- reject same as above using HEAD
- reject detached HEAD

Resolved question: the detached HEAD case; HEAD's reflog was considered and rejected as too broad for purpose in the original review. cf. [2]

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com> [2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>

Tyler Cipriani (2):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes detached HEAD advice
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++
 builtin/send-pack.c              |  5 +++
 remote.c                         | 27 +++++++++++-
 remote.h                         | 10 +++--
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              | 70 +++++++++++++++++++++++++++++++-
 transport-helper.c               |  5 +++
 transport.c                      |  8 ++++
 transport.h                      |  1 +
 12 files changed, 143 insertions(+), 5 deletions(-)

Range-diff against v2: 1: da27c421ed = 1: da27c421ed push: check pushed ref for --force-if-includes 2: e07d16d53e = 2: e07d16d53e push: fix --force-if-includes detached HEAD advice

-- 
2.47.3
Tyler CiprianiSep 10, 2026, 23:05 UTC in reply to Tyler Cipriani on lore

[PATCH v3 1/2] 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.

This can cause confusing rejections or unintended data loss.
Using a command like:
    git push --force-if-includes --force-with-lease origin src:main

False rejections: when src is an up-to-date branch, but main is out-of-date or nonexistent, then the 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 if-includes check will allow the push, clobbering the remote main.

Find local reflog using ref->peer_ref. When using a refspec like HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that branch's reflog.

But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we reject the push. HEAD's reflog is too broad to tell us if the history being pushed includes the tip of the remote. Rejecting a detached HEAD already happens today (if the same-named local branch lacks the remote tip); now the detached HEAD state is explicitly rejected.

Skip deletions:
    git push --force-if-includes --force-with-lease origin :main

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.

Reported-by: Stefan Haller <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 24 ++++++++++++++++-
 t/t5533-push-cas.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+), 1 deletion(-)
Show changes to 2 files +88 −1

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..326af76eeb 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 static void check_if_includes_upstream(struct ref *remote)
 {
-	struct ref *local = get_local_ref(remote->name);
+	struct ref *local;
+	const char *name;
+	int flag;
+
+	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 = remote->peer_ref->name;
+	if (!strcmp(name, "HEAD")) {
+		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+					       "HEAD", 0, NULL, &flag);
+		if (!name || !(flag & REF_ISSYMREF)) {
+			/* detached HEAD: no per-branch reflog to consult */
+			remote->unreachable = 1;
+			return;
+		}
+	}
+
+	local = get_local_ref(name);
 	if (!local)
 		return;
 
diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
index cba26a872d..0c02151747 100755
--- a/t/t5533-push-cas.sh
+++ b/t/t5533-push-cas.sh
@@ -396,4 +396,69 @@ 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_done
-- 
2.47.3
Tyler CiprianiSep 10, 2026, 23:05 UTC in reply to Tyler Cipriani on lore

[PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice

When a --force-if-includes push is rejected due to a detached HEAD state where there is no per-branch reflog to consult, the advice is misleading:

     ! [rejected] HEAD -> main (remote ref updated since checkout)
    error: failed to push some refs to '<remote>'
    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=<ref>:<expect>
- Ignore the error with --no-force-if-includes

Add ref->unverifiable to differentiate between a detached HEAD rejection vs. a remote update rejection.

Ensure tests check the rejection message.
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 Documentation/config/advice.adoc |  4 ++++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 15 +++++++++++++++
 builtin/send-pack.c              |  5 +++++
 remote.c                         |  5 ++++-
 remote.h                         | 10 +++++++---
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              |  7 +++++--
 transport-helper.c               |  5 +++++
 transport.c                      |  8 ++++++++
 transport.h                      |  1 +
 12 files changed, 57 insertions(+), 6 deletions(-)
Show changes to 12 files +57 −6

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..a0eff8bbd6 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 available 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..9676c6241f 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -319,6 +319,12 @@ 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 the tip of the remote-tracking branch\n"
+	   "cannot be checked against a detached HEAD. If you want to push anyway,\n"
+	   "specify the expected value with '--force-with-lease=<ref>:<expect>'\n"
+	   "or use '--no-force-if-includes' 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 +367,13 @@ 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 +425,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 326af76eeb..72bfc4dbc4 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
@@ -2823,7 +2826,7 @@ static void check_if_includes_upstream(struct ref *remote)
 					       "HEAD", 0, NULL, &flag);
 		if (!name || !(flag & REF_ISSYMREF)) {
 			/* detached HEAD: no per-branch reflog to consult */
-			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 0c02151747..fe6af3f41c 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 &&
@@ -457,7 +458,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
 	)
 '
 
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,
-- 
2.47.3
Patrick SteinhardtSep 11, 2026, 06:55 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes

On Thu, Sep 10, 2026 at 05:05:05PM -0600, Tyler Cipriani wrote:
Show 6 quoted lines
> "--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.

So... in a `git push origin foo:bar` we look up the reflog for "bar" and not "foo"?

Show 9 quoted lines
> This can cause confusing rejections or unintended data loss.
> 
> Using a command like:
> 
>     git push --force-if-includes --force-with-lease origin src:main
> 
> False rejections: when src is an up-to-date branch, but main is
> out-of-date or nonexistent, then the includes check will fail telling
> users the remote ref has been updated since the last checkout.

Hm. "up-to-date branch" in relation to what? You mean if we had commits A, B and C, with C being the most recent commit, then "src" points to C and "main" points to B?

> Data loss: when src is an orphan/out-dated branch, but main is
> up-to-date, then the if-includes check will allow the push, clobbering
> the remote main.
Right, here "src" would point to B and "main" would point to C.
Show 9 quoted lines
> Find local reflog using ref->peer_ref. When using a refspec like
> HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
> branch's reflog.
> 
> But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we
> reject the push. HEAD's reflog is too broad to tell us if the history
> being pushed includes the tip of the remote. Rejecting a detached HEAD
> already happens today (if the same-named local branch lacks the remote
> tip); now the detached HEAD state is explicitly rejected.
Makes sense.
Show 7 quoted lines
> Skip deletions:
> 
>     git push --force-if-includes --force-with-lease origin :main
> 
> 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.

This part feels a bit off to me. Deletions are the most risky operation that we can do, so why would we want to just blindly allow them? There may be good reasons for this, but if so those should be documented as part of the commit message. It would probably even be sufficient to say "it has worked this way before, and we don't want to break that case".

Show 24 quoted lines
> diff --git a/remote.c b/remote.c
> index 00723b385e..326af76eeb 100644
> --- a/remote.c
> +++ b/remote.c
> @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
>   */
>  static void check_if_includes_upstream(struct ref *remote)
>  {
> -	struct ref *local = get_local_ref(remote->name);
> +	struct ref *local;
> +	const char *name;
> +	int flag;
> +
> +	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 = remote->peer_ref->name;
> +	if (!strcmp(name, "HEAD")) {
> +		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
> +					       "HEAD", 0, NULL, &flag);

Shouldn't we pass `RESOLVE_REF_READING | RESOLVE_REF_NO_RECURSE` here? Otherwise, the function will return "HEAD" even if it could not be resolved, and we don't want to recursively resolve symrefs, either.

Also, is it sufficient to single out "HEAD" here? It could for example be that the user passes "HEAD~", an object ID or really any other revision, and these should probably not be considered reachable, either, right?

Maybe we should instead verify whether this names a local reference and, if so, resolve potential symrefs to their target.

Show 19 quoted lines
> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
> index cba26a872d..0c02151747 100755
> --- a/t/t5533-push-cas.sh
> +++ b/t/t5533-push-cas.sh
> @@ -396,4 +396,69 @@ 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
> +	)
> +'
Nit: missing empty line between these two tests.
Patrick
Patrick SteinhardtSep 11, 2026, 06:55 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice

On Thu, Sep 10, 2026 at 05:05:06PM -0600, Tyler Cipriani wrote:
Show 12 quoted lines
> diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
> index 257db58918..a0eff8bbd6 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 available locally.

We don't really care about the ref being available, but rather about it being integrated, right? So maybe s/available/integrated/.

Patrick
Junio C HamanoSep 11, 2026, 15:31 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes

Tyler Cipriani <tyler@tylercipriani.com> writes:
Show 9 quoted lines
>  static void check_if_includes_upstream(struct ref *remote)
>  {
> -	struct ref *local = get_local_ref(remote->name);
> +	struct ref *local;
> +	const char *name;
> +	int flag;
> +
> +	if (!remote->peer_ref)
> +		return;

This function signals its displeasure by setting remote->unreachble to true, so any early return means it is OK to force the push, right?

What is the significance of remote not having peer_ref? Is it a usage error (i.e., push is not updating anything over there, and it makes me wonder what the command line to do so looks like)? Is it a programming error (i.e., if we are pushing to update no remote ref, this function should never be called)? If the latter, I wonder if BUG() is more appropriate.

> +	/* A deletion has no local history to check against. */
> +	if (is_null_oid(&remote->peer_ref->new_oid))
> +		return;

The comment for this condition is clear. If we are pushing to delete, checking if our side once used to build on top of theirs does not guarantee us anything, so we accept the loss of history.

Show 14 quoted lines
> +	name = remote->peer_ref->name;
> +	if (!strcmp(name, "HEAD")) {
> +		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
> +					       "HEAD", 0, NULL, &flag);
> +		if (!name || !(flag & REF_ISSYMREF)) {
> +			/* detached HEAD: no per-branch reflog to consult */
> +			remote->unreachable = 1;
> +			return;
> +		}
> +	}
> +
> +	local = get_local_ref(name);
>  	if (!local)
>  		return;
The same question here.

Are any of these silent "punt" returns tested below? It does not seem to add a new test about pushing-to-delete.

Thanks.
Show 74 quoted lines
> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
> index cba26a872d..0c02151747 100755
> --- a/t/t5533-push-cas.sh
> +++ b/t/t5533-push-cas.sh
> @@ -396,4 +396,69 @@ 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_done
Junio C HamanoSep 11, 2026, 15:40 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice

Tyler Cipriani <tyler@tylercipriani.com> writes:
Show 19 quoted lines
> When a --force-if-includes push is rejected due to a detached HEAD
> state where there is no per-branch reflog to consult, the advice is
> misleading:
>
>      ! [rejected] HEAD -> main (remote ref updated since checkout)
>     error: failed to push some refs to '<remote>'
>     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=<ref>:<expect>
> - Ignore the error with --no-force-if-includes
>
> Add ref->unverifiable to differentiate between a detached HEAD rejection
> vs. a remote update rejection.
Makes sense.
Show 13 quoted lines
> diff --git a/builtin/push.c b/builtin/push.c
> index 6021b71d66..9676c6241f 100644
> --- a/builtin/push.c
> +++ b/builtin/push.c
> @@ -319,6 +319,12 @@ 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 the tip of the remote-tracking branch\n"
> +	   "cannot be checked against a detached HEAD. If you want to push anyway,\n"
> +	   "specify the expected value with '--force-with-lease=<ref>:<expect>'\n"
> +	   "or use '--no-force-if-includes' to skip this check.");
Good.
> +static void advise_ref_unverifiable(void)
> +{
> +	if (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))
> +		return;
Line that is over +100 column wide?
> +	advise(_(message_advice_ref_unverifiable));
> +}

This is a tangent, but on a separate thread we were talking about consolidating a sequence

    if (advice_enabled(ADVICE_FOO))
	advise(_(message for FOO));
into
    advise_if_enabled(ADVICE_FOO, _(message for FOO));

This is an example of usage that falls outside of the pattern (not a bad thing; just what those who advocate more use of advise_if_enabled() need to be aware of).

Show 24 quoted lines
> diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
> index 0c02151747..fe6af3f41c 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 &&
> @@ -457,7 +458,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
>  	)
>  '
Great.
Junio C HamanoSep 11, 2026, 16:03 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v3 2/2] push: fix --force-if-includes detached HEAD advice

Patrick Steinhardt <ps@pks.im> writes:
Show 16 quoted lines
> On Thu, Sep 10, 2026 at 05:05:06PM -0600, Tyler Cipriani wrote:
>> diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
>> index 257db58918..a0eff8bbd6 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 available locally.
>
> We don't really care about the ref being available, but rather about it
> being integrated, right? So maybe s/available/integrated/.

Ah, I missed that one. "available locally" is not of interest. We cannot tell if we integrated it is what matters.

Thanks.
Tyler CiprianiSep 11, 2026, 22:58 UTC in reply to Patrick Steinhardt on lore

Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes

On Fri, Sep 11, 2026 at 12:55 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 11 quoted lines
>
> On Thu, Sep 10, 2026 at 05:05:05PM -0600, Tyler Cipriani wrote:
> > "--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.
>
> So... in a `git push origin foo:bar` we look up the reflog for "bar" and
> not "foo"?
Exactly.
Show 13 quoted lines
> > This can cause confusing rejections or unintended data loss.
> >
> > Using a command like:
> >
> >     git push --force-if-includes --force-with-lease origin src:main
> >
> > False rejections: when src is an up-to-date branch, but main is
> > out-of-date or nonexistent, then the includes check will fail telling
> > users the remote ref has been updated since the last checkout.
>
> Hm. "up-to-date branch" in relation to what? You mean if we had commits
> A, B and C, with C being the most recent commit, then "src" points to C
> and "main" points to B?

You got it. It should read something like: "False rejections: when src is up-to-date with the tip of the remote ref, but..." etc.

I can clarify in a v4.
Show 31 quoted lines
> > Data loss: when src is an orphan/out-dated branch, but main is
> > up-to-date, then the if-includes check will allow the push, clobbering
> > the remote main.
>
> Right, here "src" would point to B and "main" would point to C.
>
> > Find local reflog using ref->peer_ref. When using a refspec like
> > HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
> > branch's reflog.
> >
> > But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we
> > reject the push. HEAD's reflog is too broad to tell us if the history
> > being pushed includes the tip of the remote. Rejecting a detached HEAD
> > already happens today (if the same-named local branch lacks the remote
> > tip); now the detached HEAD state is explicitly rejected.
>
> Makes sense.
>
> > Skip deletions:
> >
> >     git push --force-if-includes --force-with-lease origin :main
> >
> > 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.
>
> This part feels a bit off to me. Deletions are the most risky operation
> that we can do, so why would we want to just blindly allow them? There
> may be good reasons for this, but if so those should be documented as
> part of the commit message. It would probably even be sufficient to say
> "it has worked this way before, and we don't want to break that case".

For deletions, there's no history on our side to check. Also, there was an existing test case that ensured deletions were allowed. I took that as intent and opted to keep that behavior. I'll clarify in the commit.

Show 28 quoted lines
> > diff --git a/remote.c b/remote.c
> > index 00723b385e..326af76eeb 100644
> > --- a/remote.c
> > +++ b/remote.c
> > @@ -2806,7 +2806,29 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
> >   */
> >  static void check_if_includes_upstream(struct ref *remote)
> >  {
> > -     struct ref *local = get_local_ref(remote->name);
> > +     struct ref *local;
> > +     const char *name;
> > +     int flag;
> > +
> > +     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 = remote->peer_ref->name;
> > +     if (!strcmp(name, "HEAD")) {
> > +             name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
> > +                                            "HEAD", 0, NULL, &flag);
>
> Shouldn't we pass `RESOLVE_REF_READING | RESOLVE_REF_NO_RECURSE` here?
> Otherwise, the function will return "HEAD" even if it could not be
> resolved, and we don't want to recursively resolve symrefs, either.
RESOLVE_REF_READING: agreed. Will add.
RESOLVE_REF_NO_RECURSE: For the current (v3) state that only looks at
"HEAD" that makes sense. But I'd expect --force-if-includes to
resolve, e.g., STABLE -> HEAD -> refs/heads/main -- that is, to
recurse through multiple symlinks. Otherwise, we'd reject a push we
could verify.
> Also, is it sufficient to single out "HEAD" here? It could for example
> be that the user passes "HEAD~", an object ID or really any other
> revision, and these should probably not be considered reachable, either,
> right?

Oooh, great catch! Folks could put in tags or specific oids, none of which have a reflog to check. These are rejected in v3, but only by happenstance since they lack a reflog (and with bad advice about running "git pull").

> Maybe we should instead verify whether this names a local reference and,
> if so, resolve potential symrefs to their target.

Yes, that makes sense. Resolve symrefs to the target branch, then check the branch's reflog.

I'll try that in v4.

This comment left me spiraling for a bit about tags. Like: you can push tags and a tag and a branch might point to the same commit. BUT tags don't have reflogs, so I think it makes sense to reject those as unverifiable here, too.

Show 21 quoted lines
> > diff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh
> > index cba26a872d..0c02151747 100755
> > --- a/t/t5533-push-cas.sh
> > +++ b/t/t5533-push-cas.sh
> > @@ -396,4 +396,69 @@ 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
> > +     )
> > +'
>
> Nit: missing empty line between these two tests.
Ack.
> Patrick
Thanks for the review!
Tyler CiprianiSep 11, 2026, 23:47 UTC in reply to Junio C Hamano on lore

Re: [PATCH v3 1/2] push: check pushed ref for --force-if-includes

On Fri, Sep 11, 2026 at 9:31 AM Junio C Hamano <gitster@pobox.com> wrote:
Show 15 quoted lines
>
> Tyler Cipriani <tyler@tylercipriani.com> writes:
>
> >  static void check_if_includes_upstream(struct ref *remote)
> >  {
> > -     struct ref *local = get_local_ref(remote->name);
> > +     struct ref *local;
> > +     const char *name;
> > +     int flag;
> > +
> > +     if (!remote->peer_ref)
> > +             return;
>
> This function signals its displeasure by setting remote->unreachble
> to true, so any early return means it is OK to force the push, right?

That's true, for each ref that will be pushed. But this return does not imply it's OK to force push; refs with no peer_ref are not part of the push. The caller (apply_push_cas) walks every ref in remote_refs, then this function gets called for each ref that has check_reachable, regardless of whether it will later be pushed.

We could move this check to apply_push_cas to winnow what check_if_includes_upstream is responsible for checking and make every bare return mean "OK to force"; i.e., change apply_push_cas from:

if (ref->check_reachable)
    check_if_includes_upstream(ref);
to:
if (ref->peer_ref && ref->check_reachable)
    check_if_includes_upstream(ref);
And drop this return (and probably add a comment). I like that better.
Show 6 quoted lines
> What is the significance of remote not having peer_ref?  Is it a
> usage error (i.e., push is not updating anything over there, and it
> makes me wonder what the command line to do so looks like)?  Is it a
> programming error (i.e., if we are pushing to update no remote ref,
> this function should never be called)?  If the latter, I wonder if
> BUG() is more appropriate.
This is an ordinary path vs. BUG(). For the command:
git --force-with-lease --force-if-includes origin main

apply_push_cas checks all advertised refs. If there's no peer_ref, then remote.c's set_ref_status_for_push skips the ref before even checking ref->unreachable. When I ran the coverage report, this guard was hit regularly.

Show 7 quoted lines
> > +     /* A deletion has no local history to check against. */
> > +     if (is_null_oid(&remote->peer_ref->new_oid))
> > +             return;
>
> The comment for this condition is clear.  If we are pushing to
> delete, checking if our side once used to build on top of theirs
> does not guarantee us anything, so we accept the loss of history.
Agreed.
Show 16 quoted lines
> > +     name = remote->peer_ref->name;
> > +     if (!strcmp(name, "HEAD")) {
> > +             name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
> > +                                            "HEAD", 0, NULL, &flag);
> > +             if (!name || !(flag & REF_ISSYMREF)) {
> > +                     /* detached HEAD: no per-branch reflog to consult */
> > +                     remote->unreachable = 1;
> > +                     return;
> > +             }
> > +     }
> > +
> > +     local = get_local_ref(name);
> >       if (!local)
> >               return;
>
> The same question here.

get_local_ref should not return null. And when I ran the coverage report, this guard never ran. I'd be happy to remove it in v4.

> Are any of these silent "punt" returns tested below?  It does not
> seem to add a new test about pushing-to-delete.
There is an existing test for push-to-delete that this patch set kept.
> Thanks.
Thanks for the review!

Patrick suggested generalizing away from checking "HEAD" and I think that's the right call. I'll try that, plus adding your feedback (plus some additional detail in comments) in a v4.

Tyler CiprianiSep 14, 2026, 04:00 UTC in reply to Tyler Cipriani on lore

[PATCH v4 0/2] push: check pushed ref for --force-if-includes

Changes since v3:
- check_if_includes_upstream unconditionally resolves peer_ref with
  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
  when using --force-if-includes
- add test for --force-if-includes tag push 1/2
- clarify log message problem example 1/2
- clarify deletion in log message 1/2
- add missing blank line between test cases
- shorten long line in builtin/push.c
- reword advice-message wording 2/2
- rename 2/2 from "detached HEAD" to "non-branch"
Changes since v2:
- Correct patch threading of 1/2 and 2/2 to reply to cover letter of
  current patchset vs. cover letter of the initial iteration.
Changes since v1:
- Clarify in log message 1/2 that --force-if-includes will reject a
  detached HEAD today (when the same-named local branch lacks the remote
  tip). And note that this change makes it explicit to always reject
  the detached HEAD case.

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
               false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow forced-update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch lacking
  branch tip
- reject same as above using HEAD
- reject detached HEAD

Resolved question: the detached HEAD case; HEAD's reflog was considered and rejected as too broad for purpose in the original review. cf. [2]

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com> [2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>

Tyler Cipriani (2):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes non-branch advice
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/push.c                   | 17 ++++++
 builtin/send-pack.c              |  5 ++
 remote.c                         | 29 ++++++++++-
 remote.h                         | 10 ++--
 send-pack.c                      |  1 +
 t/t5533-push-cas.sh              | 88 +++++++++++++++++++++++++++++++-
 transport-helper.c               |  5 ++
 transport.c                      |  8 +++
 transport.h                      |  1 +
 12 files changed, 164 insertions(+), 6 deletions(-)
Range-diff against v3:
1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes
    @@ Commit message
         on the destination branch rather than the branch being pushed; using
         ref->name vs. ref->peer_ref->name.
     
    -    This can cause confusing rejections or unintended data loss.
    -
    -    Using a command like:
    +    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
     
    -    False rejections: when src is an up-to-date branch, but main is
    -    out-of-date or nonexistent, then the includes check will fail telling
    -    users the remote ref has been updated since the last checkout.
    +    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 if-includes check will allow the push, clobbering
    -    the remote main.
    +    up-to-date, then the force-if-includes check will allow the push,
    +    clobbering the remote main.
     
    -    Find local reflog using ref->peer_ref. When using a refspec like
    -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
    -    branch's reflog.
    +    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.
     
    -    But if HEAD does not resolve to a branch (i.e. a detached HEAD), then we
    -    reject the push. HEAD's reflog is too broad to tell us if the history
    -    being pushed includes the tip of the remote. Rejecting a detached HEAD
    -    already happens today (if the same-named local branch lacks the remote
    -    tip); now the detached HEAD state is explicitly rejected.
    -
    -    Skip deletions:
    +    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 <lists@haller-berlin.de>
         Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
         Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
    @@ remote.c: static int is_reachable_in_reflog(const char *local, const struct ref
      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;
    -+	int flag;
     +
    ++	/* ref without peer_ref will not be pushed */
     +	if (!remote->peer_ref)
    -+		return;
    -+
    + 		return;
    + 
     +	/* A deletion has no local history to check against. */
     +	if (is_null_oid(&remote->peer_ref->new_oid))
     +		return;
     +
    -+	name = remote->peer_ref->name;
    -+	if (!strcmp(name, "HEAD")) {
    -+		name = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
    -+					       "HEAD", 0, NULL, &flag);
    -+		if (!name || !(flag & REF_ISSYMREF)) {
    -+			/* detached HEAD: no per-branch reflog to consult */
    -+			remote->unreachable = 1;
    -+			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 (!local)
    - 		return;
    - 
    ++
    + 	if (is_reachable_in_reflog(local->name, remote) <= 0)
    + 		remote->unreachable = 1;
    + 	free_one_ref(local);
     
      ## t/t5533-push-cas.sh ##
     @@ t/t5533-push-cas.sh: test_expect_success '"--force-if-includes" should allow deletes' '
    @@ t/t5533-push-cas.sh: test_expect_success '"--force-if-includes" should allow del
     +		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" &&
    @@ t/t5533-push-cas.sh: test_expect_success '"--force-if-includes" should allow del
     +		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
2:  e07d16d53e ! 2:  2a455d8a76 push: fix --force-if-includes detached HEAD advice
    @@ Metadata
     Author: Tyler Cipriani <tyler@tylercipriani.com>
     
      ## Commit message ##
    -    push: fix --force-if-includes detached HEAD advice
    +    push: fix --force-if-includes non-branch advice
     
    -    When a --force-if-includes push is rejected due to a detached HEAD
    -    state where there is no per-branch reflog to consult, the advice is
    -    misleading:
    +    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 '<remote>'
    @@ Commit message
         - Specify the expected remote tip with --force-with-lease=<ref>:<expect>
         - Ignore the error with --no-force-if-includes
     
    -    Add ref->unverifiable to differentiate between a detached HEAD rejection
    -    vs. a remote update rejection.
    +    Add ref->unverifiable to differentiate pushing something without a
    +    reflog to consult vs. a remote update rejection.
     
         Ensure tests check the rejection message.
     
    @@ Documentation/config/advice.adoc: all advice messages.
     +	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 available locally.
    ++		ref is integrated locally.
      	pushUnqualifiedRefname::
      		Shown when linkgit:git-push[1] gives up trying to
      		guess based on the source and destination refs what
    @@ builtin/push.c: static const char message_advice_ref_needs_update[] =
      	   "See the 'Note about fast-forwards' in 'git push --help' for details.");
      
     +static const char message_advice_ref_unverifiable[] =
    -+	N_("Updates were rejected because the tip of the remote-tracking branch\n"
    -+	   "cannot be checked against a detached HEAD. If you want to push anyway,\n"
    -+	   "specify the expected value with '--force-with-lease=<ref>:<expect>'\n"
    -+	   "or use '--no-force-if-includes' to skip this check.");
    ++	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=<ref>:<expect>' or use '--no-force-if-includes'\n"
    ++	   "to skip this check.");
     +
      static void advise_pull_before_push(void)
      {
    @@ builtin/push.c: static void advise_ref_needs_update(void)
      
     +static void advise_ref_unverifiable(void)
     +{
    -+	if (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))
    ++	if (!advice_enabled(ADVICE_PUSH_REF_UNVERIFIABLE) ||
    ++			!advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))
     +		return;
     +	advise(_(message_advice_ref_unverifiable));
     +}
    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,
      				/*
      				 * If the ref isn't stale, and is reachable
     @@ remote.c: static void check_if_includes_upstream(struct ref *remote)
    - 					       "HEAD", 0, NULL, &flag);
    - 		if (!name || !(flag & REF_ISSYMREF)) {
    - 			/* detached HEAD: no per-branch reflog to consult */
    --			remote->unreachable = 1;
    -+			remote->unverifiable = 1;
    - 			return;
    - 		}
    + 	 * then there is no reliable reflog to check
    + 	 */
    + 	if (!name || !starts_with(name, "refs/heads/")) {
    +-		remote->unreachable = 1;
    ++		remote->unverifiable = 1;
    + 		return;
      	}
    + 
     
      ## remote.h ##
     @@ remote.h: struct ref {
    @@ t/t5533-push-cas.sh: test_expect_success '"--force-if-includes" should reject fo
      	)
      '
      
    +@@ t/t5533-push-cas.sh: 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
    + 	)
    + '
    + 
     
      ## transport-helper.c ##
     @@ transport-helper.c: static int push_update_ref_status(struct strbuf *buf,
-- 
2.47.3
Tyler CiprianiSep 14, 2026, 04:00 UTC in reply to Tyler Cipriani on lore

[PATCH v4 1/2] 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 <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 26 +++++++++++++--
 t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+), 2 deletions(-)
Show changes to 2 files +105 −2

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..887c7ec00c 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 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
-- 
2.47.3
Tyler CiprianiSep 14, 2026, 04:00 UTC in reply to Tyler Cipriani on lore

[PATCH v4 2/2] 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 '<remote>'
    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=<ref>:<expect>
- 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 <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 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(-)
Show changes to 12 files +62 −7

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

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=<ref>:<expect>' 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,
-- 
2.47.3
D. Ben KnobleSep 14, 2026, 13:03 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes

Hi Tyler,
On Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:
Show 7 quoted lines
>
> Changes since v3:
>
> - check_if_includes_upstream unconditionally resolves peer_ref with
>   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
>   when using --force-if-includes
> - add test for --force-if-includes tag push 1/2
This is intriguing and seems like a significant behavior change, let's read on…
Show 15 quoted lines
> Range-diff against v3:
> 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes
>     @@ Commit message
>     -    Find local reflog using ref->peer_ref. When using a refspec like
>     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
>     -    branch's reflog.
>     +    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.

So, we would now reject a force-push whose source is anything but a branch (with force-if-includes, and that presumably includes push.useForceIfIncludes)?

Show 14 quoted lines
>     ++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
>     ++  )
>     ++'
Which is what I think this test says.

I think this would break a common thing I do at work (although this is soon to be deprecated, so take my anecdote with appropriate salt; I can't claim that no one else relies on it, of course):

As I think I described in the message you linked, I have an alias "pf = push --force-with-lease" and push.useForceIfIncludes=true in config. Our team has a "main" release branch and a "hotfix" release branch for emergencies. When hotfixing, we first reset the hotfix branch to the last tag to go out to our production environment, which I typically do like this:

    # validate that we won't lose any interesting commits (no regressions) with
    # something like
    git log --oneline --graph --boundary --cherry-mark --left-right
origin/hotfix...<TAG>
    # push
    git pf origin <TAG>:hotfix

(On a second pass before sending, I can't recall if this works as-is when I don't have a local hotfix branch tracking origin/hotfix.)

If I'm reading this version right, I would now have to say
    git pf --no-force-if-includes origin <TAG>:hotfix
or perhaps better
    git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …

probably after seeing a (hopefully improved?) message after the original command. (Do I need to disable force-if-includes in the more-specific lease command?)

Now, on the one hand, enshrining existing behavior is good for backwards compatibility but has earned us a bit of a reputation for not innovating in useful ways ;) On the other, I wonder if the description of force-if-includes allows some latitude to break with existing behavior here.

The relevant docs say
       --force-if-includes, --no-force-if-includes
           Force an update only if the tip of the remote-tracking ref has been
           integrated locally.
           This option enables a check that verifies if the tip of the
           remote-tracking ref is reachable from one of the "reflog" entries of
           the local branch based in it for a rewrite. The check ensures that
           any updates from the remote have been incorporated locally by
           rejecting the forced update if that is not the case.

It is unclear to me what "one of the 'reflog' entries of the local branch based in it" means! Ignoring that, the surrounding text only talks about whether the remote-tracking ref's tip (or "updates from the remote") have been "integrated locally."

So I think we *could* say that, in this case, we don't have enough information from "<TAG>:hotfix" to check whether "origin/hotfix" has been integrated locally or not, and we should tighten the meaning of the check. (Perhaps when "--force-with-lease=hotfix" is given, though, we now have more information available to check---but that could be outside the scope of this series if we don't mind breaking backwards compatibility now.)

Thanks, D. Ben Knoble

Tyler CiprianiSep 14, 2026, 19:27 UTC in reply to D. Ben Knoble on lore

Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes

On Mon, Sep 14, 2026 at 7:03 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:
> Hi Tyler,
Hi Ben!
Show 10 quoted lines
> On Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:
> >
> > Changes since v3:
> >
> > - check_if_includes_upstream unconditionally resolves peer_ref with
> >   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
> >   when using --force-if-includes
> > - add test for --force-if-includes tag push 1/2
>
> This is intriguing and seems like a significant behavior change, let's read on…

It's definitely true that this is a behavior change and it'll add some friction to your process. And it's also true that the current behavior is failing to provide the guarantees it claims.

Show 18 quoted lines
> > Range-diff against v3:
> > 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes
> >     @@ Commit message
> >     -    Find local reflog using ref->peer_ref. When using a refspec like
> >     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
> >     -    branch's reflog.
> >     +    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.
>
> So, we would now reject a force-push whose source is anything but a branch (with
> force-if-includes, and that presumably includes push.useForceIfIncludes)?

I should clarify, reject the force-push of any source not ultimately resolvable to a branch; e.g., HEAD will work if resolves to a branch.

Show 36 quoted lines
> >     ++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
> >     ++  )
> >     ++'
>
> Which is what I think this test says.
>
> I think this would break a common thing I do at work (although this is soon to
> be deprecated, so take my anecdote with appropriate salt; I can't claim that no
> one else relies on it, of course):
>
> As I think I described in the message you linked, I have an alias "pf = push
> --force-with-lease" and push.useForceIfIncludes=true in config. Our team has a
> "main" release branch and a "hotfix" release branch for emergencies. When
> hotfixing, we first reset the hotfix branch to the last tag to go out to our
> production environment, which I typically do like this:
>
>     # validate that we won't lose any interesting commits (no regressions) with
>     # something like
>     git log --oneline --graph --boundary --cherry-mark --left-right
> origin/hotfix...<TAG>
>     # push
>     git pf origin <TAG>:hotfix
>
> (On a second pass before sending, I can't recall if this works as-is when I
> don't have a local hotfix branch tracking origin/hotfix.)

Yes, this workflow will break. And it will not work today without a local branch named "hotfix". It's broken today, insofar as this is a false pass since push.useForceIfIncludes is unable to say anything about whether you've integrated origin's hotfix branch into the <TAG>, you're pushing so it only incidentally works.

Today, git pf is actually checking that your refs/heads/hotfix's reflog has the tip of origin's refs/heads/hotfix. But it makes no promises about <TAG>. That is, you could:

    git checkout hotfix && git pull # This line is what makes it work today
    git checkout --orphan junk
    git commit -m --allow-empty 'Totally unrelated empty commit'
    git tag <TAG>
    git pf origin <TAG>:hotfix

And pf will allow that to happen since origin/hotfix's tip has been integrated with your local refs/heads/hotfix, which is what it's checking today.

Show 11 quoted lines
> If I'm reading this version right, I would now have to say
>
>     git pf --no-force-if-includes origin <TAG>:hotfix
>
> or perhaps better
>
>     git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …
>
> probably after seeing a (hopefully improved?) message after the original
> command. (Do I need to disable force-if-includes in the more-specific lease
> command?)
    git pf --force-with-lease=hotfix:origin/hotfix origin <TAG>:hotfix

Should be sufficient and as I understand your process, that's what you're after. The explicit --force-with-lease argument makes --force-if-includes a no-op, so --no-force-if-includes should be unnecessary.

Show 28 quoted lines
> Now, on the one hand, enshrining existing behavior is good for backwards
> compatibility but has earned us a bit of a reputation for not innovating in
> useful ways ;) On the other, I wonder if the description of force-if-includes
> allows some latitude to break with existing behavior here.
>
> The relevant docs say
>
>        --force-if-includes, --no-force-if-includes
>            Force an update only if the tip of the remote-tracking ref has been
>            integrated locally.
>
>            This option enables a check that verifies if the tip of the
>            remote-tracking ref is reachable from one of the "reflog" entries of
>            the local branch based in it for a rewrite. The check ensures that
>            any updates from the remote have been incorporated locally by
>            rejecting the forced update if that is not the case.
>
> It is unclear to me what "one of the 'reflog' entries of the local branch based
> in it" means! Ignoring that, the surrounding text only talks about whether the
> remote-tracking ref's tip (or "updates from the remote") have been "integrated
> locally."
>
> So I think we *could* say that, in this case, we don't have enough information
> from "<TAG>:hotfix" to check whether "origin/hotfix" has been integrated locally
> or not, and we should tighten the meaning of the check. (Perhaps when
> "--force-with-lease=hotfix" is given, though, we now have more information
> available to check---but that could be outside the scope of this series if we
> don't mind breaking backwards compatibility now.)

From my perspective, this is similar to the detached HEAD discussion from 2020[0] where "[the reflog of HEAD not attached to a branch] _does_ answer a different question from what we actually asked."

[0]: <https://lore.kernel.org/git/nycvar.QRO.7.76.6.2009161214030.56@tvgsbejvaqbjf.bet/>

I opted for a direction requiring explicit arguments to express intent, since that's the only way to ensure --force-if-includes aligns with (how I read) the documentation and the previous discussions.

Specifically, with tags:
- tags may have a reflog, but it answers a different question vs. "has
this tag integrated changes from an upstream" it answers what oid/ref
does this tag point to
- tags may incidentally point at oids referenced by branches with
reflogs, but there may also be several branches pointed to the same
oid, so which would we choose?

BUT I just realized there is existing, more fundamental breakage with --force-if-includes here that I'm making worse.

There is one case where we do have enough information to say whether <TAG> has integrated the tip of the remote-ref locally: fast-forward push. And that's actually broken today, too :)

    git --version
    git version 2.47.3
    git clone repo.git repo && cd repo
    git commit --allow-empty -m 'Normal, no-force-needed fast forward commit'
    git reflog expire --expire=all --all
    # Regular fast-forward push fails, even though it does not require
--force to begin with
    git push --force-with-lease --force-if-includes origin main
    ! [rejected]        main -> main (remote ref updated since checkout)

Checking for fast-forward happens after --force-if-includes checks the reflog. So that will need a fix…

My change makes an existing problem more acute, and probably requires a fix before other fixes can merge. Otherwise, --force-if-includes will always fail when pushing tags and detached heads, even when they're fast forward changes, adding needless friction to otherwise safe pushes (e.g., for tags that fast-forward a branch). So v5 will require a third change that touches other functions in remote.c. :/

> Thanks,
> D. Ben Knoble
Thank you for all the review and thoughts!
D. Ben KnobleSep 14, 2026, 20:52 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v4 0/2] push: check pushed ref for --force-if-includes

On Mon, Sep 14, 2026 at 3:27 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:
Show 118 quoted lines
>
> On Mon, Sep 14, 2026 at 7:03 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:
> > Hi Tyler,
>
> Hi Ben!
>
> > On Mon, Sep 14, 2026 at 12:00 AM Tyler Cipriani <tyler@tylercipriani.com> wrote:
> > >
> > > Changes since v3:
> > >
> > > - check_if_includes_upstream unconditionally resolves peer_ref with
> > >   RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
> > >   when using --force-if-includes
> > > - add test for --force-if-includes tag push 1/2
> >
> > This is intriguing and seems like a significant behavior change, let's read on…
>
> It's definitely true that this is a behavior change and it'll add some
> friction to your process. And it's also true that the current behavior
> is failing to provide the guarantees it claims.
>
> > > Range-diff against v3:
> > > 1:  da27c421ed ! 1:  e7912c3fd0 push: check pushed ref for --force-if-includes
> > >     @@ Commit message
> > >     -    Find local reflog using ref->peer_ref. When using a refspec like
> > >     -    HEAD:refs/heads/main, we resolve HEAD. If HEAD is a branch, use that
> > >     -    branch's reflog.
> > >     +    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.
> >
> > So, we would now reject a force-push whose source is anything but a branch (with
> > force-if-includes, and that presumably includes push.useForceIfIncludes)?
>
> I should clarify, reject the force-push of any source not ultimately
> resolvable to a branch; e.g., HEAD will work if resolves to a branch.
>
> > >     ++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
> > >     ++  )
> > >     ++'
> >
> > Which is what I think this test says.
> >
> > I think this would break a common thing I do at work (although this is soon to
> > be deprecated, so take my anecdote with appropriate salt; I can't claim that no
> > one else relies on it, of course):
> >
> > As I think I described in the message you linked, I have an alias "pf = push
> > --force-with-lease" and push.useForceIfIncludes=true in config. Our team has a
> > "main" release branch and a "hotfix" release branch for emergencies. When
> > hotfixing, we first reset the hotfix branch to the last tag to go out to our
> > production environment, which I typically do like this:
> >
> >     # validate that we won't lose any interesting commits (no regressions) with
> >     # something like
> >     git log --oneline --graph --boundary --cherry-mark --left-right
> > origin/hotfix...<TAG>
> >     # push
> >     git pf origin <TAG>:hotfix
> >
> > (On a second pass before sending, I can't recall if this works as-is when I
> > don't have a local hotfix branch tracking origin/hotfix.)
>
> Yes, this workflow will break. And it will not work today without a
> local branch named "hotfix". It's broken today, insofar as this is a
> false pass since push.useForceIfIncludes is unable to say anything
> about whether you've integrated origin's hotfix branch into the <TAG>,
> you're pushing so it only incidentally works.
>
> Today, git pf is actually checking that your refs/heads/hotfix's
> reflog has the tip of origin's refs/heads/hotfix. But it makes no
> promises about <TAG>. That is, you could:
>
>     git checkout hotfix && git pull # This line is what makes it work today
>     git checkout --orphan junk
>     git commit -m --allow-empty 'Totally unrelated empty commit'
>     git tag <TAG>
>     git pf origin <TAG>:hotfix
>
> And pf will allow that to happen since origin/hotfix's tip has been
> integrated with your local refs/heads/hotfix, which is what it's
> checking today.
>
> > If I'm reading this version right, I would now have to say
> >
> >     git pf --no-force-if-includes origin <TAG>:hotfix
> >
> > or perhaps better
> >
> >     git pf --no-force-if-includes --force-with-lease=hotfix[:origin/hotfix] …
> >
> > probably after seeing a (hopefully improved?) message after the original
> > command. (Do I need to disable force-if-includes in the more-specific lease
> > command?)
>
>     git pf --force-with-lease=hotfix:origin/hotfix origin <TAG>:hotfix
>
> Should be sufficient and as I understand your process, that's what
> you're after. The explicit --force-with-lease argument makes
> --force-if-includes a no-op, so --no-force-if-includes should be
> unnecessary.
Thanks, I think this answers my questions…
Show 47 quoted lines
> > Now, on the one hand, enshrining existing behavior is good for backwards
> > compatibility but has earned us a bit of a reputation for not innovating in
> > useful ways ;) On the other, I wonder if the description of force-if-includes
> > allows some latitude to break with existing behavior here.
> >
> > The relevant docs say
> >
> >        --force-if-includes, --no-force-if-includes
> >            Force an update only if the tip of the remote-tracking ref has been
> >            integrated locally.
> >
> >            This option enables a check that verifies if the tip of the
> >            remote-tracking ref is reachable from one of the "reflog" entries of
> >            the local branch based in it for a rewrite. The check ensures that
> >            any updates from the remote have been incorporated locally by
> >            rejecting the forced update if that is not the case.
> >
> > It is unclear to me what "one of the 'reflog' entries of the local branch based
> > in it" means! Ignoring that, the surrounding text only talks about whether the
> > remote-tracking ref's tip (or "updates from the remote") have been "integrated
> > locally."
> >
> > So I think we *could* say that, in this case, we don't have enough information
> > from "<TAG>:hotfix" to check whether "origin/hotfix" has been integrated locally
> > or not, and we should tighten the meaning of the check. (Perhaps when
> > "--force-with-lease=hotfix" is given, though, we now have more information
> > available to check---but that could be outside the scope of this series if we
> > don't mind breaking backwards compatibility now.)
>
> From my perspective, this is similar to the detached HEAD discussion
> from 2020[0] where "[the reflog of HEAD not attached to a branch]
> _does_ answer a different question from what we actually asked."
>
> [0]: <https://lore.kernel.org/git/nycvar.QRO.7.76.6.2009161214030.56@tvgsbejvaqbjf.bet/>
>
> I opted for a direction requiring explicit arguments to express
> intent, since that's the only way to ensure --force-if-includes aligns
> with (how I read) the documentation and the previous discussions.
>
> Specifically, with tags:
>
> - tags may have a reflog, but it answers a different question vs. "has
> this tag integrated changes from an upstream" it answers what oid/ref
> does this tag point to
> - tags may incidentally point at oids referenced by branches with
> reflogs, but there may also be several branches pointed to the same
> oid, so which would we choose?

…and I think this makes a good case for the change (but let's see what others think).

Show 26 quoted lines
> BUT I just realized there is existing, more fundamental breakage with
> --force-if-includes here that I'm making worse.
>
> There is one case where we do have enough information to say whether
> <TAG> has integrated the tip of the remote-ref locally: fast-forward
> push. And that's actually broken today, too :)
>
>     git --version
>     git version 2.47.3
>     git clone repo.git repo && cd repo
>     git commit --allow-empty -m 'Normal, no-force-needed fast forward commit'
>     git reflog expire --expire=all --all
>     # Regular fast-forward push fails, even though it does not require
> --force to begin with
>     git push --force-with-lease --force-if-includes origin main
>     ! [rejected]        main -> main (remote ref updated since checkout)
>
> Checking for fast-forward happens after --force-if-includes checks the
> reflog. So that will need a fix…
>
> My change makes an existing problem more acute, and probably requires
> a fix before other fixes can merge. Otherwise, --force-if-includes
> will always fail when pushing tags and detached heads, even when
> they're fast forward changes, adding needless friction to otherwise
> safe pushes (e.g., for tags that fast-forward a branch). So v5 will
> require a third change that touches other functions in remote.c. :/

Personally, why --force at all then? ;) A bad habit to force things that don't need it.

Best,
-- 
D. Ben Knoble
Tyler CiprianiSep 15, 2026, 23:33 UTC in reply to Tyler Cipriani on lore

[PATCH v5 0/3] push: check pushed ref for --force-if-includes

Changes since v4:
- Add patch to series: Fix case where fast-forward pushes are being
  rejected by --force-if-includes: an existing bug that I made worse
  with the previous changes in my series.
- Add tests to cover allowed fast-forward merges when using
  --force-if-includes
Changes since v3:
- check_if_includes_upstream unconditionally resolves peer_ref with
  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
  when using --force-if-includes
- add test for --force-if-includes tag push 1/3
- clarify log message problem example 1/3
- clarify deletion in log message 1/3
- add missing blank line between test cases
- shorten long line in builtin/push.c
- reword advice-message wording 2/3
- rename 2/2 from "detached HEAD" to "non-branch"
Changes since v2:
- Correct patch threading of 1/3 and 2/3 to reply to cover letter of
  current patchset vs. cover letter of the initial iteration.
Changes since v1:
- Clarify in log message 1/3 that --force-if-includes will reject a
  detached HEAD today (when the same-named local branch lacks the remote
  tip). And note that this change makes it explicit to always reject
  the detached HEAD case.

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
               false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow fast-forward push using --force-if-includes with an expired
  reflog
- allow fast-forward push of a tag on a different-named local branch
- allow forced-update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch lacking
  branch tip
- reject same as above using HEAD
- reject detached HEAD

Resolved question: the detached HEAD case; HEAD's reflog was considered and rejected as too broad for purpose in the original review. cf. [2]

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com> [2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>

Tyler Cipriani (3):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes non-branch advice
  push: --force-if-includes should allow fast-forward
 Documentation/config/advice.adoc |   4 ++
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/push.c                   |  17 +++++
 builtin/send-pack.c              |   5 ++
 remote.c                         |  43 ++++++++++--
 remote.h                         |  10 ++-
 send-pack.c                      |   1 +
 t/t5533-push-cas.sh              | 115 ++++++++++++++++++++++++++++++-
 transport-helper.c               |   5 ++
 transport.c                      |   8 +++
 transport.h                      |   1 +
 12 files changed, 203 insertions(+), 8 deletions(-)

Range-diff against v4: 1: e7912c3fd0 = 1: e7912c3fd0 push: check pushed ref for --force-if-includes 2: 2a455d8a76 = 2: 2a455d8a76 push: fix --force-if-includes non-branch advice -: ---------- > 3: 1776f8d572 push: --force-if-includes should allow fast-forward

-- 
2.47.3
Tyler CiprianiSep 15, 2026, 23:33 UTC in reply to Tyler Cipriani on lore

[PATCH v5 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 <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 26 +++++++++++++--
 t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+), 2 deletions(-)
Show changes to 2 files +105 −2

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..887c7ec00c 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 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
-- 
2.47.3
Tyler CiprianiSep 15, 2026, 23:33 UTC in reply to Tyler Cipriani on lore

[PATCH v5 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 '<remote>'
    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=<ref>:<expect>
- 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 <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 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(-)
Show changes to 12 files +62 −7

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

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=<ref>:<expect>' 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,
-- 
2.47.3
Tyler CiprianiSep 15, 2026, 23:33 UTC in reply to Tyler Cipriani on lore

[PATCH v5 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 <tyler@tylercipriani.com>
---
 remote.c            | 16 +++++++++++++---
 t/t5533-push-cas.sh | 27 +++++++++++++++++++++++++++
 2 files changed, 40 insertions(+), 3 deletions(-)
Show changes to 2 files +40 −3

remote.c, t/t5533-push-cas.sh

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" &&
-- 
2.47.3
D. Ben KnobleSep 16, 2026, 12:29 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward

Hi Tyler,
On Tue, Sep 15, 2026 at 7:33 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:
Show 18 quoted lines
>
> 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.
"push force" ? :)
> 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.

This paragraph does not match our usual style (Documentation/SubmittingPatches[[imperative-mood]]) and feels somewhat artificial to me.

Show 33 quoted lines
> 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
>                                 /*

From these 2 hunks, I haven't yet seen the connection to avoiding a rejected force-push in the fast-forward case, but my read is: we remember why we might reject a force-push for refs whose reachability we are supposed to check.

> @@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,
>                                 reject_reason = REF_STATUS_REJECT_NONFASTFORWARD;
>                 }

Then in unshown code, in those 2 "remembered" cases, we check the must fast-forward rules. If any fail, we set reject_reason…

Show 8 quoted lines
> +               /*
> +                * 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;
> +

…which we now overwrite with our remembered reason in the rejected case. I think that makes sense.

At first I thought the unshown code above, conditional on !reject_reason, would collude to make it so "deferred_reject_reason && reject_reason" could never be true, but I was misreading the results of this patch. There are some arms in which we set both (namely, because those remembered cases don't set reject_reason, allowing the fast-forward rules checks).

I still wonder a bit about cases where we remember deferred_reject_reason and never set reject_reason, but I think those are supposed to only be the fast-forward cases. Perhaps we want to make "deferred_reject_reason" more clearly indicate that to save future readers headache if they insert code around here? I'm not sure the best way to do that, though, so maybe blaming to the log message will suffice.

-- 
D. Ben Knoble
Tyler CiprianiSep 16, 2026, 15:52 UTC in reply to D. Ben Knoble on lore

Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward

On Wed, Sep 16, 2026 at 6:29 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:
Show 24 quoted lines
>
> Hi Tyler,
>
> On Tue, Sep 15, 2026 at 7:33 PM Tyler Cipriani <tyler@tylercipriani.com> wrote:
> >
> > 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.
>
> "push force" ? :)
Whoops, good catch, thanks!
Show 8 quoted lines
> > 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.
>
> This paragraph does not match our usual style
> (Documentation/SubmittingPatches[[imperative-mood]]) and feels
> somewhat artificial to me.

Ack, I can update the mood. My goal was to make a note that moving the reachability check seems possible and might be a decent idea, but it's a lot of change in one patch.

Show 68 quoted lines
> > 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
> >                                 /*
>
> From these 2 hunks, I haven't yet seen the connection to avoiding a
> rejected force-push in the fast-forward case, but my read is: we
> remember why we might reject a force-push for refs whose reachability
> we are supposed to check.
>
> > @@ -1746,6 +1748,14 @@ void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,
> >                                 reject_reason = REF_STATUS_REJECT_NONFASTFORWARD;
> >                 }
>
> Then in unshown code, in those 2 "remembered" cases, we check the must
> fast-forward rules. If any fail, we set reject_reason…
>
> > +               /*
> > +                * 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;
> > +
>
> …which we now overwrite with our remembered reason in the rejected
> case. I think that makes sense.
>
> At first I thought the unshown code above, conditional on
> !reject_reason, would collude to make it so "deferred_reject_reason &&
> reject_reason" could never be true, but I was misreading the results
> of this patch. There are some arms in which we set both (namely,
> because those remembered cases don't set reject_reason, allowing the
> fast-forward rules checks).
>
> I still wonder a bit about cases where we remember
> deferred_reject_reason and never set reject_reason, but I think those
> are supposed to only be the fast-forward cases.
That's correct to me, too.

I hemmed and hawed a bit about whether to only check _some of_ the reject_reasons from the fast-forward check. But decided that the advice in 2/3 would get people to the right outcome in cases I could think of.

Show 5 quoted lines
> Perhaps we want to
> make "deferred_reject_reason" more clearly indicate that to save
> future readers headache if they insert code around here? I'm not sure
> the best way to do that, though, so maybe blaming to the log message
> will suffice.

I tried to indicate the rationale with comments, but I'm open to changing the variable name, too. I felt that the "deferred" in the name captured it, but the name also feels a little broad vs. what it does.

Before I take a stab at a reroll for commit message updates + variable names, I'd like to gather more feedback on the direction and implementation of this series.

Thanks you for your thoughtful comments, Ben! I've appreciated how you've helped me think about this feature.

Ben KnobleSep 16, 2026, 17:53 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v5 3/3] push: --force-if-includes should allow fast-forward

Show 6 quoted lines
> 
> Le 16 sept. 2026 à 11:52, Tyler Cipriani <tyler@tylercipriani.com> a écrit :
> 
> On Wed, Sep 16, 2026 at 6:29 AM D. Ben Knoble <ben.knoble@gmail.com> wrote:
>> 
>> Hi Tyler,
[snip]
Show 14 quoted lines
>> Perhaps we want to
>> make "deferred_reject_reason" more clearly indicate that to save
>> future readers headache if they insert code around here? I'm not sure
>> the best way to do that, though, so maybe blaming to the log message
>> will suffice.
> 
> I tried to indicate the rationale with comments, but I'm open to
> changing the variable name, too. I felt that the "deferred" in the
> name captured it, but the name also feels a little broad vs. what it
> does.
> 
> Before I take a stab at a reroll for commit message updates + variable
> names, I'd like to gather more feedback on the direction and
> implementation of this series.
Yes, I think that’s a good idea :)
> Thanks you for your thoughtful comments, Ben! I've appreciated how
> you've helped me think about this feature.
You’re quite welcome, thank you!
Tyler CiprianiSep 17, 2026, 22:43 UTC in reply to Tyler Cipriani on lore

[PATCH v6 0/3] push: check pushed ref for --force-if-includes

Changes since v5:
- Update reflog rejection variable name to signal its use
  s/deferred_reject_reason/needs_force_reject_reason 3/3
- Typo fix: s/push force/force push/ in log message 3/3
- Remove imperative mood from log message 3/3
- Edit log message/comments for clarity and ensure the terms used for
  each agree 3/3
- Restored missing function words "to" and "a" in log message 2/3
Changes since v4:
- Add patch to series: Fix case where fast-forward pushes are being
  rejected by --force-if-includes: an existing bug that I made worse
  with the previous changes in my series.
- Add tests to cover allowed fast-forward merges when using
  --force-if-includes
Changes since v3:
- check_if_includes_upstream unconditionally resolves peer_ref with
  RESOLVE_REF_READING, now all non-branch ref pushes will be rejected
  when using --force-if-includes
- add test for --force-if-includes tag push 1/3
- clarify log message problem example 1/3
- clarify deletion in log message 1/3
- add missing blank line between test cases
- shorten long line in builtin/push.c
- reword advice-message wording 2/3
- rename 2/3 from "detached HEAD" to "non-branch"
Changes since v2:
- Correct patch threading of 1/3 and 2/3 to reply to cover letter of
  current patchset vs. cover letter of the initial iteration.
Changes since v1:
- Clarify in log message 1/3 that --force-if-includes will reject a
  detached HEAD today (when the same-named local branch lacks the remote
  tip). And note that this change makes it explicit to always reject the
  detached HEAD case.

--force-if-includes has been checking the reflog of the local branch named after the destination branch regardless of what's being pushed. This can cause false rejections or unintended data loss.

False rejection has been reported twice that I could find:
- 2023-07-26 - Stefan Haller reported local branch with a different name
  false rejection[0]
- 2025-05-08 - D. Ben Knoble reported detached HEAD false rejection[1]

The same root cause can result in data loss: when a same-name local branch contains the remote tip but you --force-if-includes push an unrelated branch, clobbering the remote repo. PoCs are in t/t5533-push-cas.sh -- new test cases fail against maint, but pass with patches applied.

Existing tests covered refspecs with different names for --force-with-lease, but missed --force-if-includes. New patches cover:

- allow fast-forward push using --force-if-includes with an expired
  reflog
- allow fast-forward push of a tag on a different-named local branch
- allow forced update using refspec with different-named local branch
- allow same as above, but with HEAD
- reject force-update using refspec with different-named local branch
  lacking branch tip
- reject same as above using HEAD
- reject detached HEAD
- reject tag
- allow fast-forward push with an expired reflog
- allow fast-forward push from a tag

Resolved question: the detached HEAD case; HEAD's reflog was considered and rejected as too broad for purpose in the original review. cf. [2]

Reject non-branch pushes due to lacking suitable reflogs for --force-if-includes to determine if remote was integrated into ref being pushed.

Fixes false rejections for pushes which never required force when using --force-if-includes.

[0]: <https://lore.kernel.org/git/f51c73ed-eb03-83ca-fb31-d3e2645c9a63@haller-berlin.de> [1]: <https://lore.kernel.org/git/CALnO6CCk0SgwObQRnpd5Pt_DvCKF8dBmyVHivU6Nr_O-GusGLA@mail.gmail.com> [2]: <https://lore.kernel.org/git/CAHLx=O=tVhtiZpaRP9TpfiBfOMS2xPe3c3=mC3VNEdBrLOioFg@mail.gmail.com>

Tyler Cipriani (3):
  push: check pushed ref for --force-if-includes
  push: fix --force-if-includes non-branch advice
  push: --force-if-includes should allow fast-forward
 Documentation/config/advice.adoc |   4 ++
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/push.c                   |  17 +++++
 builtin/send-pack.c              |   5 ++
 remote.c                         |  43 ++++++++++--
 remote.h                         |  10 ++-
 send-pack.c                      |   1 +
 t/t5533-push-cas.sh              | 115 ++++++++++++++++++++++++++++++-
 transport-helper.c               |   5 ++
 transport.c                      |   8 +++
 transport.h                      |   1 +
 12 files changed, 203 insertions(+), 8 deletions(-)
Range-diff against v5:
1:  e7912c3fd0 = 1:  e7912c3fd0 push: check pushed ref for --force-if-includes
2:  2a455d8a76 ! 2:  f85041efa8 push: fix --force-if-includes non-branch advice
    @@ Metadata
      ## Commit message ##
         push: fix --force-if-includes non-branch advice
     
    -    When a --force-if-includes push is rejected due lacking reflog to
    +    When a --force-if-includes push is rejected due to lacking a reflog to
         consult, the advice is misleading:
     
              ! [rejected] HEAD -> main (remote ref updated since checkout)
3:  1776f8d572 ! 3:  f9d97b0644 push: --force-if-includes should allow fast-forward
    @@ Metadata
      ## Commit message ##
         push: --force-if-includes should allow fast-forward
     
    -    In set_ref_status_for_push, we verify --force-if-includes's reflog
    +    In set_ref_status_for_push, we apply --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:
    @@ Commit message
         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.
    +    Instead, defer reflog rejection for --force-if-includes until after
    +    determining if a force push 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.
    +    Remember the reflog rejection reason as needs_force_reject_reason. If
    +    the fast-forward rules reject the push for a ref, show the reflog
    +    rejection reason to preserve existing behavior. But if fast-forward
    +    rules allow a push (a fast-forward, deletion, or new ref), then a force
    +    push is unneeded, the reflog rejection reason is discarded, and the push
    +    proceeds.
     
         Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
     
    @@ remote.c: 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;
    ++		int needs_force_reject_reason = 0;
      
      		if (ref->peer_ref)
      			oidcpy(&ref->new_oid, &ref->peer_ref->new_oid);
    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,
      		 * 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.
    ++		 * rejection in case force push is needed.
      		 */
      		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 =
    ++				needs_force_reject_reason =
      					REF_STATUS_REJECT_REMOTE_UPDATED;
      			else if (ref->check_reachable && ref->unverifiable)
     -				reject_reason =
    -+				deferred_reject_reason =
    ++				needs_force_reject_reason =
      					REF_STATUS_REJECT_UNVERIFIABLE;
      			else
      				/*
    @@ remote.c: void set_ref_status_for_push(struct ref *remote_refs, int send_mirror,
      		}
      
     +		/*
    -+		 * 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 fast-forward rules rejected the push 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;
    ++		if (needs_force_reject_reason && reject_reason)
    ++			reject_reason = needs_force_reject_reason;
     +
      		/*
      		 * "--force" will defeat any rejection implemented
-- 
2.47.3
Tyler CiprianiSep 17, 2026, 22:43 UTC in reply to Tyler Cipriani on lore

[PATCH v6 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 <lists@haller-berlin.de>
Reported-by: D. Ben Knoble <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 26 +++++++++++++--
 t/t5533-push-cas.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+), 2 deletions(-)
Show changes to 2 files +105 −2

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index 00723b385e..887c7ec00c 100644
--- a/remote.c
+++ b/remote.c
@@ -2806,10 +2806,32 @@ static int is_reachable_in_reflog(const char *local, const struct ref *remote)
  */
 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
-- 
2.47.3
Tyler CiprianiSep 17, 2026, 22:43 UTC in reply to Tyler Cipriani on lore

[PATCH v6 2/3] push: fix --force-if-includes non-branch advice

When a --force-if-includes push is rejected due to lacking a reflog to consult, the advice is misleading:

     ! [rejected] HEAD -> main (remote ref updated since checkout)
    error: failed to push some refs to '<remote>'
    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=<ref>:<expect>
- 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 <ben.knoble@gmail.com>
Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 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(-)
Show changes to 12 files +62 −7

Documentation/config/advice.adoc, advice.c, advice.h, builtin/push.c, builtin/send-pack.c, remote.c, remote.h, send-pack.c, t/t5533-push-cas.sh, transport-helper.c, transport.c, transport.h

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=<ref>:<expect>' 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,
-- 
2.47.3
Tyler CiprianiSep 17, 2026, 22:43 UTC in reply to Tyler Cipriani on lore

[PATCH v6 3/3] push: --force-if-includes should allow fast-forward

In set_ref_status_for_push, we apply --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 reflog rejection for --force-if-includes until after determining if a force push is needed.

Remember the reflog rejection reason as needs_force_reject_reason. If the fast-forward rules reject the push for a ref, show the reflog rejection reason to preserve existing behavior. But if fast-forward rules allow a push (a fast-forward, deletion, or new ref), then a force push is unneeded, the reflog rejection reason is discarded, and the push proceeds.

Signed-off-by: Tyler Cipriani <tyler@tylercipriani.com>
---
 remote.c            | 16 +++++++++++++---
 t/t5533-push-cas.sh | 27 +++++++++++++++++++++++++++
 2 files changed, 40 insertions(+), 3 deletions(-)
Show changes to 2 files +40 −3

remote.c, t/t5533-push-cas.sh

diff --git a/remote.c b/remote.c
index b7b5ac0d28..1ea1d2209c 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 needs_force_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 force push is needed.
 		 */
 		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 =
+				needs_force_reject_reason =
 					REF_STATUS_REJECT_REMOTE_UPDATED;
 			else if (ref->check_reachable && ref->unverifiable)
-				reject_reason =
+				needs_force_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 fast-forward rules rejected the push and we were
+		 * asked to verify the reflog but were unable to, then
+		 * reflog verification is the right reject_reason.
+		 */
+		if (needs_force_reject_reason && reject_reason)
+			reject_reason = needs_force_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" &&
-- 
2.47.3
Tyler CiprianiOct 5, 2026, 21:26 UTC in reply to Tyler Cipriani on lore

Re: [PATCH v6 0/3] push: check pushed ref for --force-if-includes

Adding Patrick to CC, like I should've from v4 onwards. Whoops!
Patrick: to address your review, since v4, I no longer special-case HEAD
(as in v3), and, instead, resolve any passed source ref. Now, if the
source ref resolves to a branch, we check that branch's reflog for the
remote tip. But for any other source (e.g., tag, oid, detached HEAD), I
reject the push as unverifiable. I'd value your opinion on whether that
matches up with what you meant.

I'm rejecting anything other than a branch reflog as "unverifiable" as other reflogs fail to record the integration info we need for --force-if-includes. HEAD's reflog spans all branches (rejected in the OG review, c. 2020), tag reflogs (when they exist) record where the tag pointed. And while a source tag/oid may be the same oid as the tip of a branch, using that to map a tag/oid to a branch seems specious: many branches could point to the same commit with no way to say which branch's reflog to check.

Ben and I have talked a bit about the consequences of rejecting non-branch pushes with --force-if-includes, viz: it breaks workflows that give the appearance of working today. For example, pushing <tag>:hotfix is allowed today (if you have a local "hotfix" branch whose reflog looks right), but --force-if-includes has never checked anything about the tag. 3/3 lets fast-forward, non-branch pushes through; 2/3's advice points to --force-with-lease=<ref>:<expect> for the rest.

Very interested in others' opinions about this tradeoff.
Note: Junio flagged a trivial textual conflict in t5533 with
as/push-force-if-includes-no-reflog: both topics add tests after the
same existing test.

There's a small conflict against the tip of maint now, too. a85a43c480 (push: suggest <remote> <branch> for a slash slip, 2026-06-27) adds some advice that sorts alphabetically after my 2/3. Happy to send a rebased v7 if that's helpful.

Thanks.

Back to recent threads