promisor-remote: allow a client to store fields

A previous commit allowed a server to pass additional fields through the "promisor-remote" protocol capability after the "name" and "url" fields, specifically the "partialCloneFilter" and "token" fields. Another previous commit, c213820c51 (promisor-remote: allow a client to check fields, 2025-09-08), has made it possible for a client to decide if it accepts a promisor remote advertised by a server based on these additional fields. Often though, it would be interesting for the client to just store in its configuration files these additional fields passed by the server, so that it can use them when needed. For example if a token is necessary to access a promisor remote, that token could be updated frequently only on the server side and then passed to all the clients through the "promisor-remote" capability, avoiding the need to update it on all the clients manually. Storing the token on the client side makes sure that the token is available when the client needs to access the promisor remotes for a lazy fetch. To allow this, let's introduce a new "promisor.storeFields" configuration variable. Note that for a partial clone filter, it's less interesting to have it stored on the client. This is because a filter should be used right away and we already pass a `--filter=<filter-spec>` option to `git clone` when starting a partial clone. Storing the filter could perhaps still be interesting for information purposes. Like "promisor.checkFields" and "promisor.sendFields", the new configuration variable should contain a comma or space separated list of field names. Only the "partialCloneFilter" and "token" field names are supported for now. When a server advertises a promisor remote, for example "foo", along with for example "token=XXXXX" to a client, and on the client side "promisor.storeFields" contains "token", then the client will store XXXXX for the "remote.foo.token" variable in its configuration file and reload its configuration so it can immediately use this new configuration variable. A message is emitted on stderr to warn users when the config is changed. Note that even if "promisor.acceptFromServer" is set to "all", a promisor remote has to be already configured on the client side for some of its config to be changed. In any case no new remote is configured and no new URL is stored. Signed-off-by: Christian Couder <chriscool@tuxfamily.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Christian Couder committed Feb 16, 2026 at 14:23 UTC 3e20258d11f188189c18bb6c4f2b2eee47abf03d
4 files changed +250 -6
Documentation/config/promisor.adoc
+33
@@ -89,3 +89,36 @@ variable. The fields are checked only if the
89 `promisor.acceptFromServer` config variable is not set to "None". If
90 set to "None", this config variable has no effect. See
91 linkgit:gitprotocol-v2[5].
92 +
93 +promisor.storeFields::
94 + A comma or space separated list of additional remote related
95 + field names. If a client accepts an advertised remote, the
96 + client will store the values associated with these field names
97 + taken from the remote advertisement into its configuration,
98 + and then reload its remote configuration. Currently,
99 + "partialCloneFilter" and "token" are the only supported field
100 + names.
101 ++
102 +For example if a server advertises "partialCloneFilter=blob:limit=20k"
103 +for remote "foo", and that remote is accepted, then "blob:limit=20k"
104 +will be stored for the "remote.foo.partialCloneFilter" configuration
105 +variable.
106 ++
107 +If the new field value from an advertised remote is the same as the
108 +existing field value for that remote on the client side, then no
109 +change is made to the client configuration though.
110 ++
111 +When a new value is stored, a message is printed to standard error to
112 +let users know about this.
113 ++
114 +Note that for security reasons, if the remote is not already
115 +configured on the client side, nothing will be stored for that
116 +remote. In any case, no new remote will be created and no URL will be
117 +stored.
118 ++
119 +Before storing a partial clone filter, it's parsed to check it's
120 +valid. If it's not, a warning is emitted and it's not stored.
121 ++
122 +Before storing a token, a check is performed to ensure it contains no
123 +control character. If the check fails, a warning is emitted and it's
124 +not stored.
Documentation/gitprotocol-v2.adoc
+7 -5
@@ -826,9 +826,10 @@ are case-sensitive and MUST be transmitted exactly as specified
826 above. Clients MUST ignore fields they don't recognize to allow for
827 future protocol extensions.
828
829 -For now, the client can only use information transmitted through these
830 -fields to decide if it accepts the advertised promisor remote. In the
831 -future that information might be used for other purposes though.
829 +The client can use information transmitted through these fields to
830 +decide if it accepts the advertised promisor remote. Also, the client
831 +can be configured to store the values of these fields (see
832 +"promisor.storeFields" in linkgit:git-config[1]).
833
834 Field values MUST be urlencoded.
835
@@ -856,8 +857,9 @@ the server advertised, the client shouldn't advertise the
857 On the server side, the "promisor.advertise" and "promisor.sendFields"
858 configuration options can be used to control what it advertises. On
859 the client side, the "promisor.acceptFromServer" configuration option
859 -can be used to control what it accepts. See the documentation of these
860 -configuration options for more information.
860 +can be used to control what it accepts, and the "promisor.storeFields"
861 +option, to control what it stores. See the documentation of these
862 +configuration options in linkgit:git-config[1] for more information.
863
864 Note that in the future it would be nice if the "promisor-remote"
865 protocol capability could be used by the server, when responding to
promisor-remote.c
+147 -1
@@ -403,6 +403,14 @@ static struct string_list *fields_checked(void)
403 return initialize_fields_list(&fields_list, &initialized, "promisor.checkFields");
404 }
405
406 +static struct string_list *fields_stored(void)
407 +{
408 + static struct string_list fields_list = STRING_LIST_INIT_NODUP;
409 + static int initialized;
410 +
411 + return initialize_fields_list(&fields_list, &initialized, "promisor.storeFields");
412 +}
413 +
414 /*
415 * Struct for promisor remotes involved in the "promisor-remote"
416 * protocol capability.
@@ -692,6 +700,132 @@ static struct promisor_info *parse_one_advertised_remote(const char *remote_info
700 return info;
701 }
702
703 +static bool store_one_field(struct repository *repo, const char *remote_name,
704 + const char *field_name, const char *field_key,
705 + const char *advertised, const char *current)
706 +{
707 + if (advertised && (!current || strcmp(current, advertised))) {
708 + char *key = xstrfmt("remote.%s.%s", remote_name, field_key);
709 +
710 + fprintf(stderr, _("Storing new %s from server for remote '%s'.\n"
711 + " '%s' -> '%s'\n"),
712 + field_name, remote_name,
713 + current ? current : "",
714 + advertised);
715 +
716 + repo_config_set_gently(repo, key, advertised);
717 + free(key);
718 +
719 + return true;
720 + }
721 +
722 + return false;
723 +}
724 +
725 +/* Check that a filter is valid by parsing it */
726 +static bool valid_filter(const char *filter, const char *remote_name)
727 +{
728 + struct list_objects_filter_options filter_opts = LIST_OBJECTS_FILTER_INIT;
729 + struct strbuf err = STRBUF_INIT;
730 + int res = gently_parse_list_objects_filter(&filter_opts, filter, &err);
731 +
732 + if (res)
733 + warning(_("invalid filter '%s' for remote '%s' "
734 + "will not be stored: %s"),
735 + filter, remote_name, err.buf);
736 +
737 + list_objects_filter_release(&filter_opts);
738 + strbuf_release(&err);
739 +
740 + return !res;
741 +}
742 +
743 +/* Check that a token doesn't contain any control character */
744 +static bool valid_token(const char *token, const char *remote_name)
745 +{
746 + const char *c = token;
747 +
748 + for (; *c; c++)
749 + if (iscntrl(*c)) {
750 + warning(_("invalid token '%s' for remote '%s' "
751 + "will not be stored"),
752 + token, remote_name);
753 + return false;
754 + }
755 +
756 + return true;
757 +}
758 +
759 +struct store_info {
760 + struct repository *repo;
761 + struct string_list config_info;
762 + bool store_filter;
763 + bool store_token;
764 +};
765 +
766 +static struct store_info *store_info_new(struct repository *repo)
767 +{
768 + struct string_list *fields_to_store = fields_stored();
769 + struct store_info *s = xmalloc(sizeof(*s));
770 +
771 + s->repo = repo;
772 +
773 + string_list_init_nodup(&s->config_info);
774 + promisor_config_info_list(repo, &s->config_info, fields_to_store);
775 + string_list_sort(&s->config_info);
776 +
777 + s->store_filter = !!string_list_lookup(fields_to_store, promisor_field_filter);
778 + s->store_token = !!string_list_lookup(fields_to_store, promisor_field_token);
779 +
780 + return s;
781 +}
782 +
783 +static void store_info_free(struct store_info *s)
784 +{
785 + if (s) {
786 + promisor_info_list_clear(&s->config_info);
787 + free(s);
788 + }
789 +}
790 +
791 +static bool promisor_store_advertised_fields(struct promisor_info *advertised,
792 + struct store_info *store_info)
793 +{
794 + struct promisor_info *p;
795 + struct string_list_item *item;
796 + const char *remote_name = advertised->name;
797 + bool reload_config = false;
798 +
799 + if (!(store_info->store_filter || store_info->store_token))
800 + return false;
801 +
802 + /*
803 + * Get existing config info for the advertised promisor
804 + * remote. This ensures the remote is already configured on
805 + * the client side.
806 + */
807 + item = string_list_lookup(&store_info->config_info, remote_name);
808 +
809 + if (!item)
810 + return false;
811 +
812 + p = item->util;
813 +
814 + if (store_info->store_filter && advertised->filter &&
815 + valid_filter(advertised->filter, remote_name))
816 + reload_config |= store_one_field(store_info->repo, remote_name,
817 + "filter", promisor_field_filter,
818 + advertised->filter, p->filter);
819 +
820 + if (store_info->store_token && advertised->token &&
821 + valid_token(advertised->token, remote_name))
822 + reload_config |= store_one_field(store_info->repo, remote_name,
823 + "token", promisor_field_token,
824 + advertised->token, p->token);
825 +
826 + return reload_config;
827 +}
828 +
829 static void filter_promisor_remote(struct repository *repo,
830 struct strvec *accepted,
831 const char *info)
@@ -700,7 +834,9 @@ static void filter_promisor_remote(struct repository *repo,
834 enum accept_promisor accept = ACCEPT_NONE;
835 struct string_list config_info = STRING_LIST_INIT_NODUP;
836 struct string_list remote_info = STRING_LIST_INIT_DUP;
837 + struct store_info *store_info = NULL;
838 struct string_list_item *item;
839 + bool reload_config = false;
840
841 if (!repo_config_get_string_tmp(the_repository, "promisor.acceptfromserver", &accept_str)) {
842 if (!*accept_str || !strcasecmp("None", accept_str))
@@ -736,14 +872,24 @@ static void filter_promisor_remote(struct repository *repo,
872 string_list_sort(&config_info);
873 }
874
739 - if (should_accept_remote(accept, advertised, &config_info))
875 + if (should_accept_remote(accept, advertised, &config_info)) {
876 + if (!store_info)
877 + store_info = store_info_new(repo);
878 + if (promisor_store_advertised_fields(advertised, store_info))
879 + reload_config = true;
880 +
881 strvec_push(accepted, advertised->name);
882 + }
883
884 promisor_info_free(advertised);
885 }
886
887 promisor_info_list_clear(&config_info);
888 string_list_clear(&remote_info, 0);
889 + store_info_free(store_info);
890 +
891 + if (reload_config)
892 + repo_promisor_remote_reinit(repo);
893 }
894
895 char *promisor_remote_reply(const char *info)
t/t5710-promisor-remote-capability.sh
+63
@@ -360,6 +360,69 @@ test_expect_success "clone with promisor.checkFields" '
360 check_missing_objects server 1 "$oid"
361 '
362
363 +test_expect_success "clone with promisor.storeFields=partialCloneFilter" '
364 + git -C server config promisor.advertise true &&
365 + test_when_finished "rm -rf client" &&
366 +
367 + git -C server remote add otherLop "https://invalid.invalid" &&
368 + git -C server config remote.otherLop.token "fooBar" &&
369 + git -C server config remote.otherLop.stuff "baz" &&
370 + git -C server config remote.otherLop.partialCloneFilter "blob:limit=10k" &&
371 + test_when_finished "git -C server remote remove otherLop" &&
372 +
373 + git -C server config remote.lop.token "fooXXX" &&
374 + git -C server config remote.lop.partialCloneFilter "blob:limit=8k" &&
375 +
376 + test_config -C server promisor.sendFields "partialCloneFilter, token" &&
377 + test_when_finished "rm trace" &&
378 +
379 + # Clone from server to create a client
380 + GIT_TRACE_PACKET="$(pwd)/trace" GIT_NO_LAZY_FETCH=0 git clone \
381 + -c remote.lop.promisor=true \
382 + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
383 + -c remote.lop.url="file://$(pwd)/lop" \
384 + -c remote.lop.token="fooYYY" \
385 + -c remote.lop.partialCloneFilter="blob:none" \
386 + -c promisor.acceptfromserver=All \
387 + -c promisor.storeFields=partialcloneFilter \
388 + --no-local --filter="blob:limit=5k" server client 2>err &&
389 +
390 + # Check that the filter from the server is stored
391 + echo "blob:limit=8k" >expected &&
392 + git -C client config remote.lop.partialCloneFilter >actual &&
393 + test_cmp expected actual &&
394 +
395 + # Check that user is notified when the filter is stored
396 + test_grep "Storing new filter from server for remote '\''lop'\''" err &&
397 + test_grep "'\''blob:none'\'' -> '\''blob:limit=8k'\''" err &&
398 +
399 + # Check that the token from the server is NOT stored
400 + echo "fooYYY" >expected &&
401 + git -C client config remote.lop.token >actual &&
402 + test_cmp expected actual &&
403 + test_grep ! "Storing new token from server" err &&
404 +
405 + # Check that the filter for an unknown remote is NOT stored
406 + test_must_fail git -C client config remote.otherLop.partialCloneFilter >actual &&
407 +
408 + # Check that the largest object is still missing on the server
409 + check_missing_objects server 1 "$oid" &&
410 +
411 + # Change the configuration on the server and fetch from the client
412 + git -C server config remote.lop.partialCloneFilter "blob:limit=7k" &&
413 + GIT_NO_LAZY_FETCH=0 git -C client fetch \
414 + --filter="blob:limit=5k" ../server 2>err &&
415 +
416 + # Check that the fetch updated the configuration on the client
417 + echo "blob:limit=7k" >expected &&
418 + git -C client config remote.lop.partialCloneFilter >actual &&
419 + test_cmp expected actual &&
420 +
421 + # Check that user is notified when the new filter is stored
422 + test_grep "Storing new filter from server for remote '\''lop'\''" err &&
423 + test_grep "'\''blob:limit=8k'\'' -> '\''blob:limit=7k'\''" err
424 +'
425 +
426 test_expect_success "clone with promisor.advertise set to 'true' but don't delete the client" '
427 git -C server config promisor.advertise true &&
428