Subject: [PATCH 4/4] send-pack: pass --must-have for push negotiation

From: "Derrick Stolee via GitGitGadget" <[email-masked]>
Date: Wed, 08 Apr 2026 14:36:10 +0000

From: Derrick Stolee <[email-masked]>

When push.negotiate is enabled, send-pack spawns a 'git fetch
--negotiate-only' subprocess to discover common commits. Previously
this subprocess had no way to include must-have refs in the
negotiation.

Add a must_have field to send_pack_args, set it from the transport
layer where the remote struct is available, and pass explicit
--must-have arguments to the negotiation subprocess. This approach
directly passes the resolved config values rather than relying on the
subprocess to read remote config, which is more robust when the URL
alone is used as the remote identifier.

Signed-off-by: Derrick Stolee <[email-masked]>
---
 send-pack.c           | 12 +++++++++++-
 send-pack.h           |  1 +
 t/t5516-fetch-push.sh | 15 +++++++++++++++
 transport.c           |  1 +
 4 files changed, 28 insertions(+), 1 deletion(-)

diff --git a/send-pack.c b/send-pack.c
index 67d6987b1c..baa52680bb 100644
--- a/send-pack.c
+++ b/send-pack.c
@@ -433,6 +433,7 @@ static void reject_invalid_nonce(const char *nonce, int len)
 
 static void get_commons_through_negotiation(struct repository *r,
 					    const char *url,
+					    const struct string_list *must_have,
 					    const struct ref *remote_refs,
 					    struct oid_array *commons)
 {
@@ -452,6 +453,14 @@ static void get_commons_through_negotiation(struct repository *r,
 			nr_negotiation_tip++;
 		}
 	}
+
+	if (must_have) {
+		struct string_list_item *item;
+		for_each_string_list_item(item, must_have)
+			strvec_pushf(&child.args, "--must-have=%s",
+				     item->string);
+	}
+
 	strvec_push(&child.args, url);
 
 	if (!nr_negotiation_tip) {
@@ -528,7 +537,8 @@ int send_pack(struct repository *r,
 	repo_config_get_bool(r, "push.negotiate", &push_negotiate);
 	if (push_negotiate) {
 		trace2_region_enter("send_pack", "push_negotiate", r);
-		get_commons_through_negotiation(r, args->url, remote_refs, &commons);
+		get_commons_through_negotiation(r, args->url, args->must_have,
+					       remote_refs, &commons);
 		trace2_region_leave("send_pack", "push_negotiate", r);
 	}
 
diff --git a/send-pack.h b/send-pack.h
index c5ded2d200..194a1898e5 100644
--- a/send-pack.h
+++ b/send-pack.h
@@ -18,6 +18,7 @@ struct repository;
 
 struct send_pack_args {
 	const char *url;
+	const struct string_list *must_have;
 	unsigned verbose:1,
 		quiet:1,
 		porcelain:1,
diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
index ac8447f21e..9272609eac 100755
--- a/t/t5516-fetch-push.sh
+++ b/t/t5516-fetch-push.sh
@@ -254,6 +254,21 @@ test_expect_success 'push with negotiation does not attempt to fetch submodules'
 	! grep "Fetching submodule" err
 '
 
+test_expect_success 'push with negotiation and remote.<name>.mustHave' '
+	test_when_finished rm -rf musthave &&
+	mk_empty musthave &&
+	git push musthave $the_first_commit:refs/remotes/origin/first_commit &&
+	test_commit -C musthave unrelated_commit &&
+	git -C musthave config receive.hideRefs refs/remotes/origin/first_commit &&
+	test_when_finished "rm event" &&
+	GIT_TRACE2_EVENT="$(pwd)/event" \
+		git -c protocol.version=2 -c push.negotiate=1 \
+		-c remote.musthave.mustHave=refs/heads/main \
+		push musthave refs/heads/main:refs/remotes/origin/main &&
+	test_grep \"key\":\"total_rounds\" event &&
+	grep_wrote 2 event # 1 commit, 1 tree
+'
+
 test_expect_success 'push without wildcard' '
 	mk_empty testrepo &&
 
diff --git a/transport.c b/transport.c
index 90923a640a..e65f896ff3 100644
--- a/transport.c
+++ b/transport.c
@@ -921,6 +921,7 @@ static int git_transport_push(struct transport *transport, struct ref *remote_re
 	args.atomic = !!(flags & TRANSPORT_PUSH_ATOMIC);
 	args.push_options = transport->push_options;
 	args.url = transport->url;
+	args.must_have = &transport->remote->must_have;
 
 	if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
 		args.push_cert = SEND_PACK_PUSH_CERT_ALWAYS;
-- 
gitgitgadget