{"thread":{"id":"65145","subject":"[RFC PATCH 0/2] push: add support for pushing to remote groups","startedAt":"2026-03-05T22:32:54Z","lastAt":"2026-05-18T18:27:36Z","messageCount":39,"participants":["Usman Akinyemi","Junio C Hamano","Kristoffer Haugsbakk"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"538005","messageId":"20260305223248.170785-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":null,"subject":"[RFC PATCH 0/2] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-05T22:32:46Z","receivedAt":"2026-03-05T22:32:54Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into two patches:\n\n  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nRFC notes and open questions:\n\n  - The current implementation pushes to group members sequentially.\n    A follow-up could add push.parallel (mirroring fetch.parallel) to\n    allow parallel pushes to group members via run_processes_parallel(),\n    the same mechanism fetch uses. Feedback on whether this is\n    desirable for push is welcome.\n\n  - push.default = simple interacts poorly with group pushes when the\n    current branch has no upstream set, since setup_default_push_refspecs()\n    will die on the first remote that is not the upstream. Users should\n    use push.default = current or explicit refspecs for group pushes.\n    It is worth discussing whether the group push path should automatically\n    imply push.default = current, or whether a clear error message\n    directing the user to configure this would be sufficient.\n\n  - force-with-lease semantics across a group push are currently\n    unmodified — the same CAS constraints are forwarded to every remote\n    in the group. Whether this is the right behaviour or whether\n    per-remote lease tracking is needed is an open question.\n\n  - I will also add the tests and documentations in the next iterations\n\nUsman Akinyemi (2):\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n builtin/fetch.c | 42 -----------------------\n builtin/push.c  | 89 ++++++++++++++++++++++++++++++++++++++-----------\n remote.c        | 37 ++++++++++++++++++++\n remote.h        | 12 +++++++\n 4 files changed, 119 insertions(+), 61 deletions(-)\n\n-- \n2.53.0\n\n"},{"id":"538006","messageId":"20260305223248.170785-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260305223248.170785-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH 1/2] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-05T22:32:47Z","receivedAt":"2026-03-05T22:32:57Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 573c295241..6e56c484bf 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex f6980dc656..75fdb4019d 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..fa38f951a2 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.53.0\n\n"},{"id":"538007","messageId":"20260305223248.170785-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260305223248.170785-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-05T22:32:48Z","receivedAt":"2026-03-05T22:33:01Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nA known interaction: push.default = simple will die when the current\nbranch has no upstream configured, because setup_default_push_refspecs()\nrequires an upstream for that mode. Users pushing to a group should set\npush.default = current or supply explicit refspecs. This is consistent\nwith how fetch handles default refspec resolution per remote.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/push.c | 89 +++++++++++++++++++++++++++++++++++++++-----------\n 1 file changed, 70 insertions(+), 19 deletions(-)\n\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 5b6cebbb85..a98fb4c934 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -551,12 +551,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP; /* represent remote or remote group */\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n+\tstruct remote *remote = NULL;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -625,25 +626,35 @@ int cmd_push(int argc,\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group))\n+\t\t\tdie(_(\"no such remote or remote group: %s\"), repo);\n+\t} else {\n+\t\tremote = pushremote_get(NULL);\n+\t\tif (!remote)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"));\n \t}\n \n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n+\t/*\n+\t * set_refspecs and mirror detection must not use `remote`\n+\t * when it may be NULL (group path). For the single-remote case,\n+\t * handle them here. For the group case they are handled\n+\t * per-remote inside the loop below.\n+\t */\n+\tif (remote) {\n+\t\tif (argc > 0)\n+\t\t\tset_refspecs(argv + 1, argc - 1, remote);\n \n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\t\tif (remote->mirror)\n+\t\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\t}\n \n \tif (flags & TRANSPORT_PUSH_ALL) {\n \t\tif (argc >= 2)\n@@ -661,10 +672,50 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\tif (remote) {\n+\t\trc = do_push(flags, push_options, remote);\n+\t} else {\n+\t\tint base_flags = flags;\n+\t\tfor (int i = 0; i < remote_group.nr; i++) {\n+\t\t\tint iter_flags = base_flags;\n+\t\t\tstruct remote *r = pushremote_get(remote_group.items[i].string);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t\t    remote_group.items[i].string);\n+\n+\t\t\t/*\n+\t\t\t * Rebuild rs from scratch for each remote so that\n+\t\t\t * push mappings (remote.NAME.push config) are resolved\n+\t\t\t * against this specific remote. Without this, mappings\n+\t\t\t * from a previous iteration would accumulate in rs and\n+\t\t\t * each remote would be pushed an ever-growing refspec list.\n+\t\t\t */\n+\t\t\trefspec_clear(&rs);\n+\t\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\t\tif (tags)\n+\t\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\t\tif (argc > 0)\n+\t\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\t\t/*\n+\t\t\t * Compute mirror flag from a fresh base each iteration\n+\t\t\t * so that a mirror remote does not bleed TRANSPORT_PUSH_FORCE\n+\t\t\t * into subsequent non-mirror remotes in the same group.\n+\t\t\t */\n+\t\t\tif (r->mirror)\n+\t\t\t\titer_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\t\trc |= do_push(iter_flags, push_options, r);\n+\t\t}\n+\t}\n+\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\n-- \n2.53.0\n\n"},{"id":"538100","messageId":"xmqqcy1g25fl.fsf@gitster.g","threadId":"65145","inReplyTo":"20260305223248.170785-2-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-06T18:12:14Z","receivedAt":"2026-03-06T18:12:17Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> diff --git a/remote.h b/remote.h\n> index fc052945ee..fa38f951a2 100644\n> --- a/remote.h\n> +++ b/remote.h\n> @@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n>  \n>  int branch_merge_matches(struct branch *, int n, const char *);\n>  \n> +/* list of the remote in a group as configured */\n> +struct remote_group_data {\n> +\tconst char *name;\n> +\tstruct string_list *list;\n> +};\n> +\n> +int get_remote_group(const char *key, const char *value,\n> +\t\t\t    const struct config_context *ctx UNUSED,\n> +\t\t\t    void *priv);\n\nIt is dubious to carry UNUESD over to an external declaration in a\npublic header file, unless it is a \"static inline\" definition that\ncomes with the implementation.\n\nOther than that, move looks correct and it is generally a good idea.\n\nWhen moving functions and types that have been private to the\nimplementation of a subsystem to public namespace, we need to be\ncareful to consider if the names of these things are specific\nenough.  With \"remote_group\" in them, they are all good as-is in\nthis case, and can go public without giving them \"better\" names.\n\nThanks.\n\n"},{"id":"538160","messageId":"xmqq4imsv13x.fsf@gitster.g","threadId":"65145","inReplyTo":"20260305223248.170785-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-07T02:12:50Z","receivedAt":"2026-03-07T02:12:53Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> -\tremote = pushremote_get(repo);\n> -\tif (!remote) {\n> -\t\tif (repo)\n> -\t\t\tdie(_(\"bad repository '%s'\"), repo);\n> -\t\tdie(_(\"No configured push destination.\\n\"\n> -\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n> -\t\t    \"\\n\"\n> -\t\t    \"    git remote add <name> <url>\\n\"\n> -\t\t    \"\\n\"\n> -\t\t    \"and then push using the remote name\\n\"\n> -\t\t    \"\\n\"\n> -\t\t    \"    git push <name>\\n\"));\n> +\tif (repo) {\n> +\t\tif (!add_remote_or_group(repo, &remote_group))\n> +\t\t\tdie(_(\"no such remote or remote group: %s\"), repo);\n> +\t} else {\n> +\t\tremote = pushremote_get(NULL);\n> +\t\tif (!remote)\n> +\t\t\tdie(_(\"No configured push destination.\\n\"\n> +\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n> +\t\t\t    \"\\n\"\n> +\t\t\t    \"    git remote add <name> <url>\\n\"\n> +\t\t\t    \"\\n\"\n> +\t\t\t    \"and then push using the remote name\\n\"\n> +\t\t\t    \"\\n\"\n> +\t\t\t    \"    git push <name>\\n\"));\n>  \t}\n\nThe basic idea to use \"remote\" (the default remote cannot be multiple)\nvs \"remote_group\" (the command line gave which remotes to talk with)\nsounds good.\n\nBut I started wondering what happens when the command line gave a\nsingle remote to talk with.  Probably we want a code that does\n\n\tif (remote_group has only one remote)\n\t\tremote = take the sole remote from the remote_group;\n\nhere before we continue.  Or the other way around and we handle the\n\"default remote cannot be multiple\" case as a special case, e.g.\n\n\tif (remote) {\n\t\tcreate remote_group with a single member \"remote\";\n\t\tremote = NULL;\n\t}\n\nand then we do not have to do ...\n\n> +\t/*\n> +\t * set_refspecs and mirror detection must not use `remote`\n> +\t * when it may be NULL (group path). For the single-remote case,\n> +\t * handle them here. For the group case they are handled\n> +\t * per-remote inside the loop below.\n> +\t */\n\n... \"handle them here because single-remote is special\" at all, no?\n\nI would prefer to avoid \"X must be done for each remote in the\nremote-group, but Y can be done only once\", as future developers\nwill get it wrong when they add their own Z and consider which side\nZ falls into.  The code structure that removes special case would\nhelp by making sure that a singleton case is special only because\nthe loop over remote_group runs once, and otherwise there is nothing\nspecial goes on.\n\nThanks.\n"},{"id":"538230","messageId":"CAPSxiM83ejdkO5pbtHT5Svsm2iePtuEAA1UE0-H0Txoe0aNckA@mail.gmail.com","threadId":"65145","inReplyTo":"xmqqcy1g25fl.fsf@gitster.g","subject":"Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-09T00:43:54Z","receivedAt":"2026-03-09T00:44:06Z","isPatch":true,"body":"> > +int get_remote_group(const char *key, const char *value,\n> > +                         const struct config_context *ctx UNUSED,\n> > +                         void *priv);\n>\n> It is dubious to carry UNUESD over to an external declaration in a\n> public header file, unless it is a \"static inline\" definition that\n> comes with the implementation.\nNoted, thanks.\n>\n> Other than that, move looks correct and it is generally a good idea.\n>\n> When moving functions and types that have been private to the\n> implementation of a subsystem to public namespace, we need to be\n> careful to consider if the names of these things are specific\n> enough.  With \"remote_group\" in them, they are all good as-is in\n> this case, and can go public without giving them \"better\" names.\nNoted, thanks.\n>\n> Thanks.\n>\n"},{"id":"538242","messageId":"CAPSxiM_KVU7rE49=omWUwaYS-u_J6eQPDgTRjPop1gj6BM1qKQ@mail.gmail.com","threadId":"65145","inReplyTo":"xmqq4imsv13x.fsf@gitster.g","subject":"Re: [RFC PATCH 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-09T00:56:49Z","receivedAt":"2026-03-09T00:57:01Z","isPatch":true,"body":">\n> The basic idea to use \"remote\" (the default remote cannot be multiple)\n> vs \"remote_group\" (the command line gave which remotes to talk with)\n> sounds good.\n>\n> But I started wondering what happens when the command line gave a\n> single remote to talk with.  Probably we want a code that does\n>\n>         if (remote_group has only one remote)\n>                 remote = take the sole remote from the remote_group;\nMake sense.\n>\n> here before we continue.  Or the other way around and we handle the\n> \"default remote cannot be multiple\" case as a special case, e.g.\n>\n>         if (remote) {\n>                 create remote_group with a single member \"remote\";\n>                 remote = NULL;\n>         }\n>\n> and then we do not have to do ...\n>\n> > +     /*\n> > +      * set_refspecs and mirror detection must not use `remote`\n> > +      * when it may be NULL (group path). For the single-remote case,\n> > +      * handle them here. For the group case they are handled\n> > +      * per-remote inside the loop below.\n> > +      */\n>\n> ... \"handle them here because single-remote is special\" at all, no?\n>\n> I would prefer to avoid \"X must be done for each remote in the\n> remote-group, but Y can be done only once\", as future developers\n> will get it wrong when they add their own Z and consider which side\n> Z falls into.  The code structure that removes special case would\n> help by making sure that a singleton case is special only because\n> the loop over remote_group runs once, and otherwise there is nothing\n> special goes on.\n\nYeah, that is a good design and makes sense. Thanks.\n\nAlso, in the cover letter, I asked some questions. I think you might\nhave missed it.\n\nQuoting here again:\n\"\n  - push.default = simple interacts poorly with group pushes when the\n    current branch has no upstream set, since setup_default_push_refspecs()\n    will die on the first remote that is not the upstream. Users should\n    use push.default = current or explicit refspecs for group pushes.\n    It is worth discussing whether the group push path should automatically\n    imply push.default = current, or whether a clear error message\n    directing the user to configure this would be sufficient.\n\n  - force-with-lease semantics across a group push are currently\n    unmodified — the same CAS constraints are forwarded to every remote\n    in the group. Whether this is the right behaviour or whether\n    per-remote lease tracking is needed is an open question.\n\"\n\nI will want feedback on this also.\n\nThanks\n"},{"id":"538264","messageId":"xmqqzf4h6s3b.fsf@gitster.g","threadId":"65145","inReplyTo":"CAPSxiM_KVU7rE49=omWUwaYS-u_J6eQPDgTRjPop1gj6BM1qKQ@mail.gmail.com","subject":"Re: [RFC PATCH 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-09T13:38:16Z","receivedAt":"2026-03-09T13:38:19Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> Also, in the cover letter, I asked some questions. I think you might\n> have missed it.\n>\n> Quoting here again:\n> \"\n>   - push.default = simple interacts poorly with group pushes when the\n>     current branch has no upstream set, since setup_default_push_refspecs()\n>     will die on the first remote that is not the upstream. Users should\n>     use push.default = current or explicit refspecs for group pushes.\n>     It is worth discussing whether the group push path should automatically\n>     imply push.default = current, or whether a clear error message\n>     directing the user to configure this would be sufficient.\n>\n>   - force-with-lease semantics across a group push are currently\n>     unmodified — the same CAS constraints are forwarded to every remote\n>     in the group. Whether this is the right behaviour or whether\n>     per-remote lease tracking is needed is an open question.\n> \"\n>\n> I will want feedback on this also.\n\nQuite honestly, I do not have strong opinions on either of these\npoints, primarily because the answer would become self evident if we\nfollow a simple general principle to explain this feature to end\nusers, which is:\n\nWhen you have N remotes r1, r2, ..., rN, and a remote group G that\nexpands (possibly recursively) to these N remotes, then for any and\nall values of $options and $args, this command invocation\n\n    $ git push $options G $args\n\nshould mean exactly the same thing as\n\n    $ git push $options r1 $args\n    $ git push $options r2 $args\n    ...\n    $ git push $options rN $args\n\nSo the answer to the first one would be:\n\n    \"git push r1\" (without any other parameters) may work while \"git\n    push r2\" (the same, wihtout any other parameters) may fail,\n    depending on how the push.default is set and on what branch you\n    run these two pushes.  \"git push G\" should behave the same way.\n    There is nothing extra fancy needs to be done.  If the user\n    wants to push to these N remotes as a whole in an identical way\n    by using remote group G, they are the one who is responsible to\n    make this sequences of pushes \"git push r1; git push r2; ...\"\n    make sense.\n\nThe answer to the second one would be derived the same way.  If\n$options includes the \"--force-with-lease=<commit>\", then the\ncommand should behave as if copies of the command that pushes to\nthese N remotes, \"git push --force-with-lease=<commit> r$i\", are\ninvoked.  If <commit> is not given (which is not a recommended even\nfor pushes to a single remote, because with background fetching,\nguesses based on the remote-tracking branches are never be\nreliable), the command may guess what commit to expect on the remote\nthe same way as if these N independent pushes are made without using\ngroup feature (which of course may make different guesses for each\nremote, if their remote-tracking branches are pointing at different\ncommits).\n\n\n\n\n"},{"id":"539322","messageId":"20260318204028.1010487-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260305223248.170785-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v2 0/2] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-18T20:40:26Z","receivedAt":"2026-03-18T20:40:34Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into two patches:\n\n  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nRFC notes and open questions:\n\n  - The current implementation pushes to group members sequentially.\n    A follow-up could add push.parallel (mirroring fetch.parallel) to\n    allow parallel pushes to group members via run_processes_parallel(),\n    the same mechanism fetch uses. Feedback on whether this is\n    desirable for push is welcome.\n\n  - push.default = simple interacts poorly with group pushes when the\n    current branch has no upstream set, since setup_default_push_refspecs()\n    will die on the first remote that is not the upstream. Users should\n    use push.default = current or explicit refspecs for group pushes.\n    It is worth discussing whether the group push path should automatically\n    imply push.default = current, or whether a clear error message\n    directing the user to configure this would be sufficient.\n\n  - force-with-lease semantics across a group push are currently\n    unmodified — the same CAS constraints are forwarded to every remote\n    in the group. Whether this is the right behaviour or whether\n    per-remote lease tracking is needed is an open question.\n\n  - I will also add the tests and documentations in the next iterations\n\nChanges in v2:\n  - Remove UNUSED from the declaration in remote.h (patch 1).\n  - Drop the persistent `remote` variable from cmd_push entirely\n    (patch 2). Following Junio's suggestion, the default remote\n    case now folds into remote_group so the single-remote and\n    group cases are handled by a single unified loop. There is\n    no longer any structural difference between pushing to one\n    remote and pushing to a group — a singleton is just a group\n    of one.\n  - Move the --mirror+refspec and --all+refspec conflict checks\n    inside the loop so they are evaluated per remote.\n  - Add a URL/path fallback so that direct path arguments like\n      git push /tmp/foo.git\n    continue to work correctly after the remote resolution\n    change.\n  - Add a test script t5528-push-group.sh covering the new\n    group push behaviour.\n  - Update Documentation/git-push.adoc: DESCRIPTION, the\n    <repository> argument description, and a new REMOTE GROUPS\n    section documenting the defining principle that\n      git push <options> all-remotes <args>\n    is exactly equivalent to running git push <options> r$i <args>\n    for each member remote independently.\n\n\nUsman Akinyemi (2):\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n Documentation/git-push.adoc |  76 +++++++++++++++++++---\n builtin/fetch.c             |  42 ------------\n builtin/push.c              | 124 ++++++++++++++++++++++++++----------\n remote.c                    |  37 +++++++++++\n remote.h                    |  12 ++++\n t/meson.build               |   1 +\n t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++\n 7 files changed, 303 insertions(+), 84 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\n-- \n2.53.0\n\n"},{"id":"539323","messageId":"20260318204028.1010487-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260318204028.1010487-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v2 1/2] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-18T20:40:27Z","receivedAt":"2026-03-18T20:40:37Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 573c295241..6e56c484bf 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex f6980dc656..75fdb4019d 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..8ff2bd88fa 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+                    const struct config_context *ctx,\n+                    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.53.0\n\n"},{"id":"539324","messageId":"20260318204028.1010487-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260318204028.1010487-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-18T20:40:28Z","receivedAt":"2026-03-18T20:40:41Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nA known interaction: push.default = simple will die when the current\nbranch has no upstream configured, because setup_default_push_refspecs()\nrequires an upstream for that mode. Users pushing to a group should set\npush.default = current or supply explicit refspecs. This is consistent\nwith how fetch handles default refspec resolution per remote.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n Documentation/git-push.adoc |  76 +++++++++++++++++++---\n builtin/push.c              | 124 ++++++++++++++++++++++++++----------\n t/meson.build               |   1 +\n t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++\n 4 files changed, 254 insertions(+), 42 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\ndiff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\nindex e5ba3a6742..1a3c309002 100644\n--- a/Documentation/git-push.adoc\n+++ b/Documentation/git-push.adoc\n@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n \n DESCRIPTION\n -----------\n-\n-Updates one or more branches, tags, or other references in a remote\n-repository from your local repository, and sends all necessary data\n-that isn't already on the remote.\n+Updates one or more branches, tags, or other references in one or more\n+remote repositories from your local repository, and sends all necessary\n+data that isn't already on the remote.\n \n The simplest way to push is `git push <remote> <branch>`.\n `git push origin main` will push the local `main` branch to the `main`\n branch on the remote named `origin`.\n \n-The `<repository>` argument defaults to the upstream for the current branch,\n-or `origin` if there's no configured upstream.\n+You can also push to multiple remotes at once by using a remote group.\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"origin gitlab backup\"\n+\n+Then `git push all-remotes` will push to `origin`, `gitlab`, and\n+`backup` in turn, as if you had run `git push` against each one\n+individually.  Each remote is pushed independently using its own\n+push mapping configuration. There is a `remotes.<group>` entry in\n+the configuration file. (See linkgit:git-config[1]).\n+\n+The `<repository>` argument defaults to the upstream for the current\n+branch, or `origin` if there's no configured upstream.\n \n To decide which branches, tags, or other refs to push, Git uses\n (in order of precedence):\n@@ -55,8 +66,10 @@ OPTIONS\n _<repository>_::\n \tThe \"remote\" repository that is the destination of a push\n \toperation.  This parameter can be either a URL\n-\t(see the section <<URLS,GIT URLS>> below) or the name\n-\tof a remote (see the section <<REMOTES,REMOTES>> below).\n+\t(see the section <<URLS,GIT URLS>> below), the name\n+\tof a remote (see the section <<REMOTES,REMOTES>> below),\n+\tor the name of a remote group\n+\t(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).\n \n `<refspec>...`::\n \tSpecify what destination ref to update with what source object.\n@@ -430,6 +443,53 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.\n \n include::urls-remotes.adoc[]\n \n+[[REMOTE-GROUPS]]\n+REMOTE GROUPS\n+-------------\n+\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"r1 r2 r3\"\n+\n+When a group name is given as the `<repository>` argument, the push is\n+performed to each member remote in turn.  The defining principle is:\n+\n+\tgit push <options> all-remotes <args>\n+\n+is exactly equivalent to:\n+\n+\tgit push <options> r1 <args>\n+\tgit push <options> r2 <args>\n+\t...\n+\tgit push <options> rN <args>\n+\n+where r1, r2, ..., rN are the members of `all-remotes`.  No special\n+behaviour is added or removed — the group is purely a shorthand for\n+running the same push command against each member remote individually.\n+\n+This means the user is responsible for ensuring that the sequence of\n+individual pushes makes sense.  For example, if `push.default = simple`\n+is set and the current branch has no upstream configured, then\n+`git push r1` may fail.  `git push all-remotes` will fail in the same\n+way, on whichever member remote triggers the condition first.  Setting\n+`push.default = current` or supplying explicit refspecs is recommended\n+when pushing to a remote group.\n+\n+Similarly, if `--force-with-lease` is given without an explicit expected\n+commit, Git will guess the expected commit for each remote independently\n+from that remote's own remote-tracking branch, the same way it would if\n+each push were run separately.  If an explicit commit is given with\n+`--force-with-lease=<refname>:<expect>`, that same value is forwarded\n+to every member remote, as if each of\n+`git push --force-with-lease=<refname>:<expect> r1`,\n+`git push --force-with-lease=<refname>:<expect> r2`, ...,\n+`git push --force-with-lease=<refname>:<expect> rN` had been invoked.\n+\n+Each member remote is pushed using its own push mapping configuration\n+(`remote.<name>.push`), so a refspec that maps differently on r1 than\n+on r2 is resolved correctly for each one.\n+\n OUTPUT\n ------\n \ndiff --git a/builtin/push.c b/builtin/push.c\nindex 5b6cebbb85..33de769a33 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -542,7 +542,6 @@ static int git_push_config(const char *k, const char *v,\n \n \treturn git_default_config(k, v, ctx, NULL);\n }\n-\n int cmd_push(int argc,\n \t     const char **argv,\n \t     const char *prefix,\n@@ -551,12 +550,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n+\tint base_flags;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP;\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -619,39 +619,45 @@ int cmd_push(int argc,\n \telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n \t\tflags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;\n \n-\tif (tags)\n-\t\trefspec_append(&rs, \"refs/tags/*\");\n-\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n-\t}\n-\n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n-\n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n-\n-\tif (flags & TRANSPORT_PUSH_ALL) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n-\t}\n-\tif (flags & TRANSPORT_PUSH_MIRROR) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group)) {\n+\t\t\t/*\n+\t\t\t * Not a configured remote name or group name.\n+\t\t\t * Try treating it as a direct URL or path, e.g.\n+\t\t\t *   git push /tmp/foo.git\n+\t\t\t *   git push https://github.com/user/repo.git\n+\t\t\t * pushremote_get() creates an anonymous remote\n+\t\t\t * from the URL so the loop below can handle it\n+\t\t\t * identically to a named remote.\n+\t\t\t */\n+\t\t\tstruct remote *r = pushremote_get(repo);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"bad repository '%s'\"), repo);\n+\t\t\tstring_list_append(&remote_group, r->name);\n+\t\t}\n+\t} else {\n+\t\tstruct remote *r = pushremote_get(NULL);\n+\t\tif (!r)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"To push to multiple remotes at once, configure a remote group using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git config remotes.<groupname> \\\"<remote1> <remote2>\\\"\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the group name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <groupname>\\n\"));\n+\t\tstring_list_append(&remote_group, r->name);\n \t}\n \n \tif (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))\n@@ -661,10 +667,60 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\t/*\n+\t * Push to each remote in remote_group. For a plain \"git push <remote>\"\n+\t * or a default push, remote_group has exactly one entry and the loop\n+\t * runs once — there is nothing structurally special about that case.\n+\t * For a group, the loop runs once per member remote.\n+\t *\n+\t * Mirror detection and the --mirror/--all + refspec conflict checks\n+\t * are done per remote inside the loop. A remote configured with\n+\t * remote.NAME.mirror=true implies mirror mode for that remote only —\n+\t * other non-mirror remotes in the same group are unaffected.\n+\t *\n+\t * rs is rebuilt from scratch for each remote so that per-remote push\n+\t * mappings (remote.NAME.push config) are resolved against the correct\n+\t * remote. iter_flags is derived from a clean snapshot of flags taken\n+\t * before the loop so that a mirror remote cannot bleed\n+\t * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the\n+\t * same group.\n+\t */\n+\tbase_flags = flags;\n+\tfor (int i = 0; i < remote_group.nr; i++) {\n+\t\tint iter_flags = base_flags;\n+\t\tstruct remote *r = pushremote_get(remote_group.items[i].string);\n+\t\tif (!r)\n+\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t    remote_group.items[i].string);\n+\n+\t\tif (r->mirror)\n+\t\t\titer_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\tif (iter_flags & TRANSPORT_PUSH_ALL) {\n+\t\t\tif (argc >= 2)\n+\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n+\t\t}\n+\t\tif (iter_flags & TRANSPORT_PUSH_MIRROR) {\n+\t\t\tif (argc >= 2)\n+\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\t\t}\n+\n+\t\trefspec_clear(&rs);\n+\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\tif (tags)\n+\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\tif (argc > 0)\n+\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\trc |= do_push(iter_flags, push_options, r);\n+\t}\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 6d91470ebc..eb123f456a 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -697,6 +697,7 @@ integration_tests = [\n   't5563-simple-http-auth.sh',\n   't5564-http-proxy.sh',\n   't5565-push-multiple.sh',\n+  't5566-push-group.sh',\n   't5570-git-daemon.sh',\n   't5571-pre-push-hook.sh',\n   't5572-pull-submodule.sh',\ndiff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\nnew file mode 100755\nindex 0000000000..9e0d378f2a\n--- /dev/null\n+++ b/t/t5566-push-group.sh\n@@ -0,0 +1,95 @@\n+#!/bin/sh\n+\n+test_description='push to remote group'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit init --bare dest-$i.git &&\n+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"initial\" &&\n+\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n+\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n+\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n+\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n+\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n+\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n+\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n+'\n+\n+test_expect_success 'push to remote group pushes to all members' '\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tj= &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git for-each-ref >actual-$i &&\n+\t\tif test -n \"$j\"\n+\t\tthen\n+\t\t\ttest_cmp actual-$j actual-$i\n+\t\telse\n+\t\t\tcat actual-$i\n+\t\tfi &&\n+\t\tj=$i ||\n+\t\treturn 1\n+\tdone\n+'\n+\n+test_expect_success 'push second commit to group updates all members' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"second\" &&\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >hash-$i ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_cmp hash-1 hash-2 &&\n+\ttest_cmp hash-2 hash-3\n+'\n+\n+test_expect_success 'push to single remote in group does not affect others' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"third\" &&\n+\tgit push remote-1 HEAD:refs/heads/main &&\n+\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n+\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n+\t! test_cmp hash-after-1 hash-after-2\n+'\n+\n+test_expect_success 'push to nonexistent group fails with error' '\n+\ttest_must_fail git push no-such-group HEAD:refs/heads/main\n+'\n+\n+test_expect_success 'push explicit refspec to group' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fourth\" &&\n+\tgit push all-remotes HEAD:refs/heads/other &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_cmp other-hash-1 other-hash-2 &&\n+\ttest_cmp other-hash-2 other-hash-3\n+'\n+\n+test_expect_success 'mirror remote in group with refspec fails' '\n+\tgit config set remote.remote-1.mirror true &&\n+\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n+\tgrep \"mirror\" err &&\n+\tgit config unset remote.remote-1.mirror\n+'\n+test_expect_success 'push.default=current works with group push' '\n+\tgit config set push.default current &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fifth\" &&\n+\tgit push all-remotes &&\n+\tgit config unset push.default\n+'\n+\n+test_done\n-- \n2.53.0\n\n"},{"id":"539327","messageId":"xmqqpl50q2ib.fsf@gitster.g","threadId":"65145","inReplyTo":"20260318204028.1010487-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-18T20:57:48Z","receivedAt":"2026-03-18T20:57:52Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> A known interaction: push.default = simple will die when the current\n> branch has no upstream configured, because setup_default_push_refspecs()\n> requires an upstream for that mode. Users pushing to a group should set\n> push.default = current or supply explicit refspecs. This is consistent\n> with how fetch handles default refspec resolution per remote.\n\nEven if you are not pushing to a group but to a single remote,\ndefault=simple would die when the current branch has no upstream\nconfigured, so I do not know what the point of singling\n\"default=simple\" out is in the above paragraph.  For that matter, is\n\"default=current\" so special and would it be the only way, other\nthan giving an explicit refspec, to make the push succeed?  Wouldn't\ndefault=matching, for example, work equally well?\n\n> +When a group name is given as the `<repository>` argument, the push is\n> +performed to each member remote in turn.  The defining principle is:\n> +\n> +\tgit push <options> all-remotes <args>\n> +\n> +is exactly equivalent to:\n> +\n> +\tgit push <options> r1 <args>\n> +\tgit push <options> r2 <args>\n> +\t...\n> +\tgit push <options> rN <args>\n> +\n> +where r1, r2, ..., rN are the members of `all-remotes`.  No special\n> +behaviour is added or removed — the group is purely a shorthand for\n> +running the same push command against each member remote individually.\n\nOne thing this does not make it clear is if we abort upon the first\nfailure, or even after some fail we still attempt to push to the\nrest of the remotes in the group.\n\n> +Similarly, if `--force-with-lease` is given without an explicit expected\n> +commit, Git will guess the expected commit for each remote independently\n> +from that remote's own remote-tracking branch, the same way it would if\n> +each push were run separately.\n\nUnderstandable (even though it would be obvious from the \"exactly\nequivalent to\" above).\n\n> If an explicit commit is given with\n> +`--force-with-lease=<refname>:<expect>`, that same value is forwarded\n> +to every member remote, as if each of\n> +`git push --force-with-lease=<refname>:<expect> r1`,\n> +`git push --force-with-lease=<refname>:<expect> r2`, ...,\n> +`git push --force-with-lease=<refname>:<expect> rN` had been invoked.\n\nUnderstandable (ditto).\n\n> diff --git a/builtin/push.c b/builtin/push.c\n> index 5b6cebbb85..33de769a33 100644\n> --- a/builtin/push.c\n> +++ b/builtin/push.c\n> @@ -542,7 +542,6 @@ static int git_push_config(const char *k, const char *v,\n>  \n>  \treturn git_default_config(k, v, ctx, NULL);\n>  }\n> -\n>  int cmd_push(int argc,\n>  \t     const char **argv,\n>  \t     const char *prefix,\n\nYou didn't want to include this hunk, I presume?\n\nThanks.\n"},{"id":"539331","messageId":"xmqq7br8pzr2.fsf@gitster.g","threadId":"65145","inReplyTo":"20260318204028.1010487-1-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-18T21:57:21Z","receivedAt":"2026-03-18T21:57:24Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> This RFC series adds support for `git push` to accept a remote group\n> name (as configured via `remotes.<name>` in config) in addition to a\n> single remote name, mirroring the behaviour that `git fetch` has\n> supported for some time.\n>\n> A user with multiple remotes configured as a group can now do:\n>\n>     git push all-remotes\n>\n> instead of pushing to each remote individually, in the same way that:\n>\n>     git fetch all-remotes\n>\n> already works.\n>\n> The series is split into two patches:\n>\n>   - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n>     `remote_group_data` struct out of builtin/fetch.c and into\n>     remote.c/remote.h, making them part of the public remote API.\n>\n>   - Patch 2 extends builtin/push.c to use the newly public\n>     `add_remote_or_group()` to resolve the repository argument as\n>     either a single remote or a group, and pushes to each member of\n>     the group in turn.\n>\n> RFC notes and open questions:\n\n>   - The current implementation pushes to group members sequentially.\n>   - push.default = simple interacts poorly with group pushes when the\n>   - force-with-lease semantics across a group push are currently\n\nI am indifferent; comments from others very much welcomed.\n\n>\n>   - I will also add the tests and documentations in the next iterations\n\nHmm, is this still valid?\n\n> Changes in v2:\n>   - Remove UNUSED from the declaration in remote.h (patch 1).\n>   - Drop the persistent `remote` variable from cmd_push entirely\n>     (patch 2). Following Junio's suggestion, the default remote\n>     case now folds into remote_group so the single-remote and\n>     group cases are handled by a single unified loop. There is\n>     no longer any structural difference between pushing to one\n>     remote and pushing to a group — a singleton is just a group\n>     of one.\n>   - Move the --mirror+refspec and --all+refspec conflict checks\n>     inside the loop so they are evaluated per remote.\n>   - Add a URL/path fallback so that direct path arguments like\n>       git push /tmp/foo.git\n>     continue to work correctly after the remote resolution\n>     change.\n>   - Add a test script t5528-push-group.sh covering the new\n>     group push behaviour.\n\nI think you added 5566 instead of 5528 (the latter of which is\nalready used by another test).\n\n>   - Update Documentation/git-push.adoc: DESCRIPTION, the\n>     <repository> argument description, and a new REMOTE GROUPS\n>     section documenting the defining principle that\n>       git push <options> all-remotes <args>\n>     is exactly equivalent to running git push <options> r$i <args>\n>     for each member remote independently.\n>\n>\n> Usman Akinyemi (2):\n>   remote: move remote group resolution to remote.c\n>   push: support pushing to a remote group\n>\n>  Documentation/git-push.adoc |  76 +++++++++++++++++++---\n>  builtin/fetch.c             |  42 ------------\n>  builtin/push.c              | 124 ++++++++++++++++++++++++++----------\n>  remote.c                    |  37 +++++++++++\n>  remote.h                    |  12 ++++\n>  t/meson.build               |   1 +\n>  t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++\n>  7 files changed, 303 insertions(+), 84 deletions(-)\n>  create mode 100755 t/t5566-push-group.sh\n"},{"id":"539332","messageId":"xmqq341wpzor.fsf@gitster.g","threadId":"65145","inReplyTo":"20260318204028.1010487-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-18T21:58:44Z","receivedAt":"2026-03-18T21:58:46Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> When the argument resolves to a single remote the behaviour is\n> identical to before. When it resolves to a group, each member remote\n\nI find this hard to read without a comma after \"to a single remote\".\n"},{"id":"539333","messageId":"xmqqpl50ojvy.fsf@gitster.g","threadId":"65145","inReplyTo":"20260318204028.1010487-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-18T22:25:21Z","receivedAt":"2026-03-18T22:25:24Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\n> new file mode 100755\n> index 0000000000..9e0d378f2a\n> --- /dev/null\n> +++ b/t/t5566-push-group.sh\n> @@ -0,0 +1,95 @@\n> +#!/bin/sh\n> +\n> +test_description='push to remote group'\n> +\n> +. ./test-lib.sh\n> +\n> +test_expect_success 'setup' '\n> +\tfor i in 1 2 3\n> +\tdo\n> +\t\tgit init --bare dest-$i.git &&\n> +\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n> +\t\treturn 1\n> +\tdone &&\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"initial\" &&\n> +\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n> +\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n> +\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n> +\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n> +\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n> +\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n> +\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n> +'\n\nSo we have three remotes, dest-{1,2,3}.git/ that are all bare, and a\nremote group \"all-remotes\" that name them.  Is there a reason why\nyou want to use an unborn HEAD?\n\n> +test_expect_success 'push to remote group pushes to all members' '\n> +\tgit push all-remotes HEAD:refs/heads/main &&\n\nOur \"push\" exits with 0 status.  How would we make sure we pushed\ncorrectly?\n\n> +\tj= &&\n> +\tfor i in 1 2 3\n> +\tdo\n> +\t\tgit -C dest-$i.git for-each-ref >actual-$i &&\n\nWe grab dest-$i's refs to actual-$i\n\n> +\t\tif test -n \"$j\"\n> +\t\tthen\n> +\t\t\ttest_cmp actual-$j actual-$i\n\nand make sure if refs in dest-N differ from dest-(N-1)'s refs.\n\n> +\t\telse\n> +\t\t\tcat actual-$i\n\nof course, the first one has nothing to compare against, so we get a\ndebugging \"cat\" for it.\n\n> +\t\tfi &&\n> +\t\tj=$i ||\n> +\t\treturn 1\n\nBut does this loop test what we really want to make sure?  You could\nwrite your \"group push\" to push one commit less than what was asked\nto push out to all remotes, and they will match with each other to\npass the above test, but it would be different from our original.\n\nDon't we know the exact state of refs in these dest-$i.git\nrepositories?  If we do, then\n\n    printf \"%s commit\\trefs/heads/main\\n\" >expect &&\n    for i in 1 2 3\n    do\n\tgit -C dest-$i.git for-each-ref >actual &&\n\ttest_cmp expect actual || return 1\n    done\n\nperhaps?\n\n> +test_expect_success 'push second commit to group updates all members' '\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"second\" &&\n> +\tgit push all-remotes HEAD:refs/heads/main &&\n> +\tfor i in 1 2 3\n> +\tdo\n> +\t\tgit -C dest-$i.git rev-parse refs/heads/main >hash-$i ||\n> +\t\treturn 1\n> +\tdone &&\n> +\ttest_cmp hash-1 hash-2 &&\n> +\ttest_cmp hash-2 hash-3\n> +'\n\nAgain, the primary thing we are interested in is that dest-*.git\nhas a copy of what we pushed.  They may be identical to each other\namong themselves but they still could be different from what we\npushed, and that is something we want to catch, no?\n\n    git rev-parse refs/heads/main >expect &&\n    for i in 1 2 3\n    do\n\tgit -C dest-$i.git rev-parse refs/heads/main >actual &&\n\ttest_cmp expect actual || return 1\n    done\n\n> +\n> +test_expect_success 'push to single remote in group does not affect others' '\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"third\" &&\n> +\tgit push remote-1 HEAD:refs/heads/main &&\n> +\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n> +\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n> +\t! test_cmp hash-after-1 hash-after-2\n> +'\n\nObviously correct.\n\n> +test_expect_success 'push to nonexistent group fails with error' '\n> +\ttest_must_fail git push no-such-group HEAD:refs/heads/main\n> +'\n\nObviously correct---we probably should already have a test to see\nthat a push to nonexistent remote repository fails (missing one you\ncannot even tell if it is a single remote or a group), in which case\nthis is not even needed.\n\n> +test_expect_success 'push explicit refspec to group' '\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"fourth\" &&\n> +\tgit push all-remotes HEAD:refs/heads/other &&\n\nDidn't we do this already?  We did so with 'main' into dest-*.git\nthat did not know anything about 'main' (after its HEAD repointed\nto a missing branch).\n\n> +\tfor i in 1 2 3\n> +\tdo\n> +\t\tgit -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||\n> +\t\treturn 1\n> +\tdone &&\n> +\ttest_cmp other-hash-1 other-hash-2 &&\n> +\ttest_cmp other-hash-2 other-hash-3\n> +'\n> +\n> +test_expect_success 'mirror remote in group with refspec fails' '\n> +\tgit config set remote.remote-1.mirror true &&\n> +\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n> +\tgrep \"mirror\" err &&\n\ntest_grep??\n\n> +\tgit config unset remote.remote-1.mirror\n> +'\n> +test_expect_success 'push.default=current works with group push' '\n> +\tgit config set push.default current &&\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"fifth\" &&\n> +\tgit push all-remotes &&\n> +\tgit config unset push.default\n> +'\n> +\n> +test_done\n"},{"id":"539334","messageId":"CAPSxiM8cFLXNQfVx1V5djHD1MOzAJz83LEWKZJ5znXTO9zfngQ@mail.gmail.com","threadId":"65145","inReplyTo":"xmqq7br8pzr2.fsf@gitster.g","subject":"Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-18T23:13:15Z","receivedAt":"2026-03-18T23:13:27Z","isPatch":true,"body":">\n> >   - The current implementation pushes to group members sequentially.\n> >   - push.default = simple interacts poorly with group pushes when the\n> >   - force-with-lease semantics across a group push are currently\n>\n> I am indifferent; comments from others very much welcomed.\nYeah.\n>\n> >\n> >   - I will also add the tests and documentations in the next iterations\n>\n> Hmm, is this still valid?\nNope, I already did that, this was from v1.\n>\n\n> >     continue to work correctly after the remote resolution\n> >     change.\n> >   - Add a test script t5528-push-group.sh covering the new\n> >     group push behaviour.\n>\n> I think you added 5566 instead of 5528 (the latter of which is\n> already used by another test).\nMistake.\n"},{"id":"539408","messageId":"xmqqzf43lplf.fsf@gitster.g","threadId":"65145","inReplyTo":"20260318204028.1010487-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-19T17:02:36Z","receivedAt":"2026-03-19T17:02:39Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> +\tfor (int i = 0; i < remote_group.nr; i++) {\n\nremote_group.nr is of size_t, so the compiler will complain about\nthis comparison that stops a platform natural integer approaching\nthe limit from below for type and signedness mismatch, even though\nit would be insane to try defining a remote group whose size would\nnot fit platform natural integer.  A workaround is obvious.\n\n    \"int i = 0\" -> \"size_t i = 0\"\n"},{"id":"539979","messageId":"CAPSxiM_0RBUUOiCk8qLiY1GCKHqjbp1dgzEXEznQKxvQdFNgPg@mail.gmail.com","threadId":"65145","inReplyTo":"xmqqzf43lplf.fsf@gitster.g","subject":"Re: [RFC PATCH v2 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-25T18:42:03Z","receivedAt":"2026-03-25T18:42:16Z","isPatch":true,"body":"On Thu, Mar 19, 2026 at 10:32 PM Junio C Hamano <gitster@pobox.com> wrote:\n>     \"int i = 0\" -> \"size_t i = 0\"\nThanks for the review. I am sending a new version that addresses all these.\n"},{"id":"539983","messageId":"20260325190906.1153080-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260318204028.1010487-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v3 0/2] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-25T19:09:04Z","receivedAt":"2026-03-25T19:09:12Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into two patches:\n\n  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nChanges in v3:\n\n- Clarify documentation for remote group pushes:\n  * describe behavior on partial failures (rejection vs fatal errors)\n  * emphasize that group push is equivalent to running independent\n    pushes to each member remote\n\n- Simplify tests:\n  * remove redundant comparisons between remotes\n  * verify pushed commits against the expected value using `rev-parse`\n\n- Add tests for failure scenarios:\n  * ensure push continues on non-fast-forward rejection\n  * ensure push stops on fatal transport errors\n\n- Use `size_t` for loop index to match type of `remote_group.nr`\n  and avoid compiler warnings\n\nRange-diff v2 -> v3:\n\n1:  dd370a19e7 = 1:  dd370a19e7 remote: move remote group resolution to remote.c\n2:  ba5801cee1 ! 2:  6a7957e61c push: support pushing to a remote group\n    @@ Commit message\n     \n             git push <group>\n     \n    -    When the argument resolves to a single remote the behaviour is\n    +    When the argument resolves to a single remote, the behaviour is\n         identical to before. When it resolves to a group, each member remote\n         is pushed in sequence.\n     \n    @@ Commit message\n         a copy of the flags, so that a mirror remote in the group cannot set\n         TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n     \n    -    A known interaction: push.default = simple will die when the current\n    -    branch has no upstream configured, because setup_default_push_refspecs()\n    -    requires an upstream for that mode. Users pushing to a group should set\n    -    push.default = current or supply explicit refspecs. This is consistent\n    -    with how fetch handles default refspec resolution per remote.\n    -\n         Suggested-by: Junio C Hamano <gitster@pobox.com>\n         Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n     \n    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`\n     +behaviour is added or removed — the group is purely a shorthand for\n     +running the same push command against each member remote individually.\n     +\n    ++The behaviour upon failure depends on the kind of error encountered:\n    ++\n    ++If a member remote rejects the push, for example due to a\n    ++non-fast-forward update, force needed but not given, an existing tag,\n    ++or a server-side hook refusing a ref, Git reports the error and continues\n    ++pushing to the remaining remotes in the group. The overall exit code is\n    ++non-zero if any member push fails.\n    ++\n    ++If a member remote cannot be contacted at all, for example because the\n    ++repository does not exist, authentication fails, or the network is\n    ++unreachable, the push stops at that point and the remaining remotes\n    ++are not attempted.\n    ++\n     +This means the user is responsible for ensuring that the sequence of\n    -+individual pushes makes sense.  For example, if `push.default = simple`\n    -+is set and the current branch has no upstream configured, then\n    -+`git push r1` may fail.  `git push all-remotes` will fail in the same\n    -+way, on whichever member remote triggers the condition first.  Setting\n    -+`push.default = current` or supplying explicit refspecs is recommended\n    -+when pushing to a remote group.\n    -+\n    -+Similarly, if `--force-with-lease` is given without an explicit expected\n    -+commit, Git will guess the expected commit for each remote independently\n    -+from that remote's own remote-tracking branch, the same way it would if\n    -+each push were run separately.  If an explicit commit is given with\n    -+`--force-with-lease=<refname>:<expect>`, that same value is forwarded\n    -+to every member remote, as if each of\n    -+`git push --force-with-lease=<refname>:<expect> r1`,\n    -+`git push --force-with-lease=<refname>:<expect> r2`, ...,\n    -+`git push --force-with-lease=<refname>:<expect> rN` had been invoked.\n    -+\n    -+Each member remote is pushed using its own push mapping configuration\n    -+(`remote.<name>.push`), so a refspec that maps differently on r1 than\n    -+on r2 is resolved correctly for each one.\n    ++individual pushes makes sense. If `git push r1`` would fail for a given\n    ++set of options and arguments, then `git push all-remotes` will fail in\n    ++the same way when it reaches r1. The group push does not do anything\n    ++special to make a failing individual push succeed.\n     +\n      OUTPUT\n      ------\n      \n     \n      ## builtin/push.c ##\n    -@@ builtin/push.c: static int git_push_config(const char *k, const char *v,\n    - \n    - \treturn git_default_config(k, v, ctx, NULL);\n    - }\n    --\n    - int cmd_push(int argc,\n    - \t     const char **argv,\n    - \t     const char *prefix,\n     @@ builtin/push.c: int cmd_push(int argc,\n      \tint flags = 0;\n      \tint tags = 0;\n    @@ builtin/push.c: int cmd_push(int argc,\n     +\t * same group.\n     +\t */\n     +\tbase_flags = flags;\n    -+\tfor (int i = 0; i < remote_group.nr; i++) {\n    ++\tfor (size_t i = 0; i < remote_group.nr; i++) {\n     +\t\tint iter_flags = base_flags;\n     +\t\tstruct remote *r = pushremote_get(remote_group.items[i].string);\n     +\t\tif (!r)\n    @@ t/t5566-push-group.sh (new)\n     +test_expect_success 'setup' '\n     +\tfor i in 1 2 3\n     +\tdo\n    -+\t\tgit init --bare dest-$i.git &&\n    -+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n    -+\t\treturn 1\n    ++\t\tgit init --bare dest-$i.git || return 1\n     +\tdone &&\n     +\ttest_tick &&\n     +\tgit commit --allow-empty -m \"initial\" &&\n    @@ t/t5566-push-group.sh (new)\n     +\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n     +'\n     +\n    -+test_expect_success 'push to remote group pushes to all members' '\n    ++test_expect_success 'push to remote group updates all members correctly' '\n     +\tgit push all-remotes HEAD:refs/heads/main &&\n    -+\tj= &&\n    ++\tgit rev-parse HEAD >expect &&\n     +\tfor i in 1 2 3\n     +\tdo\n    -+\t\tgit -C dest-$i.git for-each-ref >actual-$i &&\n    -+\t\tif test -n \"$j\"\n    -+\t\tthen\n    -+\t\t\ttest_cmp actual-$j actual-$i\n    -+\t\telse\n    -+\t\t\tcat actual-$i\n    -+\t\tfi &&\n    -+\t\tj=$i ||\n    ++\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n     +\t\treturn 1\n    ++\t\ttest_cmp expect actual || return 1\n     +\tdone\n     +'\n     +\n    @@ t/t5566-push-group.sh (new)\n     +\ttest_tick &&\n     +\tgit commit --allow-empty -m \"second\" &&\n     +\tgit push all-remotes HEAD:refs/heads/main &&\n    ++\tgit rev-parse HEAD >expect &&\n     +\tfor i in 1 2 3\n     +\tdo\n    -+\t\tgit -C dest-$i.git rev-parse refs/heads/main >hash-$i ||\n    ++\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n     +\t\treturn 1\n    -+\tdone &&\n    -+\ttest_cmp hash-1 hash-2 &&\n    -+\ttest_cmp hash-2 hash-3\n    ++\t\ttest_cmp expect actual || return 1\n    ++\tdone\n     +'\n     +\n     +test_expect_success 'push to single remote in group does not affect others' '\n    @@ t/t5566-push-group.sh (new)\n     +\t! test_cmp hash-after-1 hash-after-2\n     +'\n     +\n    -+test_expect_success 'push to nonexistent group fails with error' '\n    -+\ttest_must_fail git push no-such-group HEAD:refs/heads/main\n    -+'\n    -+\n    -+test_expect_success 'push explicit refspec to group' '\n    -+\ttest_tick &&\n    -+\tgit commit --allow-empty -m \"fourth\" &&\n    -+\tgit push all-remotes HEAD:refs/heads/other &&\n    -+\tfor i in 1 2 3\n    -+\tdo\n    -+\t\tgit -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||\n    -+\t\treturn 1\n    -+\tdone &&\n    -+\ttest_cmp other-hash-1 other-hash-2 &&\n    -+\ttest_cmp other-hash-2 other-hash-3\n    -+'\n    -+\n     +test_expect_success 'mirror remote in group with refspec fails' '\n     +\tgit config set remote.remote-1.mirror true &&\n     +\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n    -+\tgrep \"mirror\" err &&\n    ++\ttest_grep \"mirror\" err &&\n     +\tgit config unset remote.remote-1.mirror\n     +'\n     +test_expect_success 'push.default=current works with group push' '\n    @@ t/t5566-push-group.sh (new)\n     +\tgit config unset push.default\n     +'\n     +\n    ++test_expect_success 'push continues past rejection to remaining remotes' '\n    ++\tfor i in c1 c2 c3\n    ++\tdo\n    ++\t\tgit init --bare dest-$i.git || return 1\n    ++\tdone &&\n    ++\tgit config set remote.c1.url \"file://$(pwd)/dest-c1.git\" &&\n    ++\tgit config set remote.c2.url \"file://$(pwd)/dest-c2.git\" &&\n    ++\tgit config set remote.c3.url \"file://$(pwd)/dest-c3.git\" &&\n    ++\tgit config set remotes.continue-group \"c1 c2 c3\" &&\n    ++\n    ++\ttest_tick &&\n    ++\tgit commit --allow-empty -m \"base for continue test\" &&\n    ++\n    ++\t# initial sync\n    ++\tgit push continue-group HEAD:refs/heads/main &&\n    ++\n    ++  # advance c2 independently\n    ++  git clone dest-c2.git tmp-c2 &&\n    ++  (\n    ++    cd tmp-c2 &&\n    ++    git checkout -b main origin/main &&\n    ++    test_commit c2_independent &&\n    ++    git push origin HEAD:refs/heads/main\n    ++  ) &&\n    ++  rm -rf tmp-c2 &&\n    ++\n    ++\ttest_tick &&\n    ++\tgit commit --allow-empty -m \"local diverging commit\" &&\n    ++\n    ++\t# push: c2 rejects, others succeed\n    ++\ttest_must_fail git push continue-group HEAD:refs/heads/main &&\n    ++\n    ++\tgit rev-parse HEAD >expect &&\n    ++\tgit -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&\n    ++\tgit -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&\n    ++\ttest_cmp expect actual-c1 &&\n    ++\ttest_cmp expect actual-c3 &&\n    ++\n    ++\t# c2 should not have the new commit\n    ++\tgit -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&\n    ++\t! test_cmp expect actual-c2\n    ++'\n    ++\n    ++test_expect_success 'fatal connection error stops remaining remotes' '\n    ++\tfor i in f1 f2 f3\n    ++\tdo\n    ++\t\tgit init --bare dest-$i.git || return 1\n    ++\tdone &&\n    ++\tgit config set remote.f1.url \"file://$(pwd)/dest-f1.git\" &&\n    ++\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\" &&\n    ++\tgit config set remote.f3.url \"file://$(pwd)/dest-f3.git\" &&\n    ++\tgit config set remotes.fatal-group \"f1 f2 f3\" &&\n    ++\n    ++\ttest_tick &&\n    ++\tgit commit --allow-empty -m \"base for fatal test\" &&\n    ++\n    ++\t# initial sync\n    ++\tgit push fatal-group HEAD:refs/heads/main &&\n    ++\n    ++\t# break f2\n    ++\tgit config set remote.f2.url \"file:///tmp/does-not-exist-$$\" &&\n    ++\n    ++\ttest_tick &&\n    ++\tgit commit --allow-empty -m \"after fatal setup\" &&\n    ++\n    ++\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n    ++\n    ++\tgit rev-parse HEAD >expect &&\n    ++\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n    ++\ttest_cmp expect actual-f1 &&\n    ++\n    ++\t# f3 should not be updated\n    ++\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n    ++\t! test_cmp expect actual-f3 &&\n    ++\n    ++\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n    ++'\n    ++\n     +test_done\n\nUsman Akinyemi (2):\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n Documentation/git-push.adoc |  73 ++++++++++++++++--\n builtin/fetch.c             |  42 ----------\n builtin/push.c              | 123 +++++++++++++++++++++--------\n remote.c                    |  37 +++++++++\n remote.h                    |  12 +++\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++\n 7 files changed, 355 insertions(+), 83 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\n-- \n2.48.0.rc0.4242.g73eb647d24.dirty\n\n"},{"id":"539984","messageId":"20260325190906.1153080-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260325190906.1153080-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v3 1/2] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-25T19:09:05Z","receivedAt":"2026-03-25T19:09:15Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 573c295241..6e56c484bf 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex f6980dc656..75fdb4019d 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..8ff2bd88fa 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+                    const struct config_context *ctx,\n+                    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.48.0.rc0.4242.g73eb647d24.dirty\n\n"},{"id":"539985","messageId":"20260325190906.1153080-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260325190906.1153080-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-25T19:09:06Z","receivedAt":"2026-03-25T19:09:19Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote, the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n Documentation/git-push.adoc |  73 ++++++++++++++++--\n builtin/push.c              | 123 +++++++++++++++++++++--------\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++\n 4 files changed, 306 insertions(+), 41 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\ndiff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\nindex e5ba3a6742..b7f617a290 100644\n--- a/Documentation/git-push.adoc\n+++ b/Documentation/git-push.adoc\n@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n \n DESCRIPTION\n -----------\n-\n-Updates one or more branches, tags, or other references in a remote\n-repository from your local repository, and sends all necessary data\n-that isn't already on the remote.\n+Updates one or more branches, tags, or other references in one or more\n+remote repositories from your local repository, and sends all necessary\n+data that isn't already on the remote.\n \n The simplest way to push is `git push <remote> <branch>`.\n `git push origin main` will push the local `main` branch to the `main`\n branch on the remote named `origin`.\n \n-The `<repository>` argument defaults to the upstream for the current branch,\n-or `origin` if there's no configured upstream.\n+You can also push to multiple remotes at once by using a remote group.\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"origin gitlab backup\"\n+\n+Then `git push all-remotes` will push to `origin`, `gitlab`, and\n+`backup` in turn, as if you had run `git push` against each one\n+individually.  Each remote is pushed independently using its own\n+push mapping configuration. There is a `remotes.<group>` entry in\n+the configuration file. (See linkgit:git-config[1]).\n+\n+The `<repository>` argument defaults to the upstream for the current\n+branch, or `origin` if there's no configured upstream.\n \n To decide which branches, tags, or other refs to push, Git uses\n (in order of precedence):\n@@ -55,8 +66,10 @@ OPTIONS\n _<repository>_::\n \tThe \"remote\" repository that is the destination of a push\n \toperation.  This parameter can be either a URL\n-\t(see the section <<URLS,GIT URLS>> below) or the name\n-\tof a remote (see the section <<REMOTES,REMOTES>> below).\n+\t(see the section <<URLS,GIT URLS>> below), the name\n+\tof a remote (see the section <<REMOTES,REMOTES>> below),\n+\tor the name of a remote group\n+\t(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).\n \n `<refspec>...`::\n \tSpecify what destination ref to update with what source object.\n@@ -430,6 +443,50 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.\n \n include::urls-remotes.adoc[]\n \n+[[REMOTE-GROUPS]]\n+REMOTE GROUPS\n+-------------\n+\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"r1 r2 r3\"\n+\n+When a group name is given as the `<repository>` argument, the push is\n+performed to each member remote in turn.  The defining principle is:\n+\n+\tgit push <options> all-remotes <args>\n+\n+is exactly equivalent to:\n+\n+\tgit push <options> r1 <args>\n+\tgit push <options> r2 <args>\n+\t...\n+\tgit push <options> rN <args>\n+\n+where r1, r2, ..., rN are the members of `all-remotes`.  No special\n+behaviour is added or removed — the group is purely a shorthand for\n+running the same push command against each member remote individually.\n+\n+The behaviour upon failure depends on the kind of error encountered:\n+\n+If a member remote rejects the push, for example due to a\n+non-fast-forward update, force needed but not given, an existing tag,\n+or a server-side hook refusing a ref, Git reports the error and continues\n+pushing to the remaining remotes in the group. The overall exit code is\n+non-zero if any member push fails.\n+\n+If a member remote cannot be contacted at all, for example because the\n+repository does not exist, authentication fails, or the network is\n+unreachable, the push stops at that point and the remaining remotes\n+are not attempted.\n+\n+This means the user is responsible for ensuring that the sequence of\n+individual pushes makes sense. If `git push r1`` would fail for a given\n+set of options and arguments, then `git push all-remotes` will fail in\n+the same way when it reaches r1. The group push does not do anything\n+special to make a failing individual push succeed.\n+\n OUTPUT\n ------\n \ndiff --git a/builtin/push.c b/builtin/push.c\nindex 5b6cebbb85..4a19d3879d 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -551,12 +551,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n+\tint base_flags;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP;\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -619,39 +620,45 @@ int cmd_push(int argc,\n \telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n \t\tflags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;\n \n-\tif (tags)\n-\t\trefspec_append(&rs, \"refs/tags/*\");\n-\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n-\t}\n-\n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n-\n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n-\n-\tif (flags & TRANSPORT_PUSH_ALL) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n-\t}\n-\tif (flags & TRANSPORT_PUSH_MIRROR) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group)) {\n+\t\t\t/*\n+\t\t\t * Not a configured remote name or group name.\n+\t\t\t * Try treating it as a direct URL or path, e.g.\n+\t\t\t *   git push /tmp/foo.git\n+\t\t\t *   git push https://github.com/user/repo.git\n+\t\t\t * pushremote_get() creates an anonymous remote\n+\t\t\t * from the URL so the loop below can handle it\n+\t\t\t * identically to a named remote.\n+\t\t\t */\n+\t\t\tstruct remote *r = pushremote_get(repo);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"bad repository '%s'\"), repo);\n+\t\t\tstring_list_append(&remote_group, r->name);\n+\t\t}\n+\t} else {\n+\t\tstruct remote *r = pushremote_get(NULL);\n+\t\tif (!r)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"To push to multiple remotes at once, configure a remote group using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git config remotes.<groupname> \\\"<remote1> <remote2>\\\"\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the group name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <groupname>\\n\"));\n+\t\tstring_list_append(&remote_group, r->name);\n \t}\n \n \tif (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))\n@@ -661,10 +668,60 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\t/*\n+\t * Push to each remote in remote_group. For a plain \"git push <remote>\"\n+\t * or a default push, remote_group has exactly one entry and the loop\n+\t * runs once — there is nothing structurally special about that case.\n+\t * For a group, the loop runs once per member remote.\n+\t *\n+\t * Mirror detection and the --mirror/--all + refspec conflict checks\n+\t * are done per remote inside the loop. A remote configured with\n+\t * remote.NAME.mirror=true implies mirror mode for that remote only —\n+\t * other non-mirror remotes in the same group are unaffected.\n+\t *\n+\t * rs is rebuilt from scratch for each remote so that per-remote push\n+\t * mappings (remote.NAME.push config) are resolved against the correct\n+\t * remote. iter_flags is derived from a clean snapshot of flags taken\n+\t * before the loop so that a mirror remote cannot bleed\n+\t * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the\n+\t * same group.\n+\t */\n+\tbase_flags = flags;\n+\tfor (size_t i = 0; i < remote_group.nr; i++) {\n+\t\tint iter_flags = base_flags;\n+\t\tstruct remote *r = pushremote_get(remote_group.items[i].string);\n+\t\tif (!r)\n+\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t    remote_group.items[i].string);\n+\n+\t\tif (r->mirror)\n+\t\t\titer_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\tif (iter_flags & TRANSPORT_PUSH_ALL) {\n+\t\t\tif (argc >= 2)\n+\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n+\t\t}\n+\t\tif (iter_flags & TRANSPORT_PUSH_MIRROR) {\n+\t\t\tif (argc >= 2)\n+\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\t\t}\n+\n+\t\trefspec_clear(&rs);\n+\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\tif (tags)\n+\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\tif (argc > 0)\n+\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\trc |= do_push(iter_flags, push_options, r);\n+\t}\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 6d91470ebc..eb123f456a 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -697,6 +697,7 @@ integration_tests = [\n   't5563-simple-http-auth.sh',\n   't5564-http-proxy.sh',\n   't5565-push-multiple.sh',\n+  't5566-push-group.sh',\n   't5570-git-daemon.sh',\n   't5571-pre-push-hook.sh',\n   't5572-pull-submodule.sh',\ndiff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\nnew file mode 100755\nindex 0000000000..b9962946c7\n--- /dev/null\n+++ b/t/t5566-push-group.sh\n@@ -0,0 +1,150 @@\n+#!/bin/sh\n+\n+test_description='push to remote group'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit init --bare dest-$i.git &&\n+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"initial\" &&\n+\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n+\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n+\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n+\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n+\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n+\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n+\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n+'\n+\n+test_expect_success 'push to remote group updates all members correctly' '\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push second commit to group updates all members' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"second\" &&\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push to single remote in group does not affect others' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"third\" &&\n+\tgit push remote-1 HEAD:refs/heads/main &&\n+\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n+\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n+\t! test_cmp hash-after-1 hash-after-2\n+'\n+\n+test_expect_success 'mirror remote in group with refspec fails' '\n+\tgit config set remote.remote-1.mirror true &&\n+\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"mirror\" err &&\n+\tgit config unset remote.remote-1.mirror\n+'\n+test_expect_success 'push.default=current works with group push' '\n+\tgit config set push.default current &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fifth\" &&\n+\tgit push all-remotes &&\n+\tgit config unset push.default\n+'\n+\n+test_expect_success 'push continues past rejection to remaining remotes' '\n+\tfor i in c1 c2 c3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.c1.url \"file://$(pwd)/dest-c1.git\" &&\n+\tgit config set remote.c2.url \"file://$(pwd)/dest-c2.git\" &&\n+\tgit config set remote.c3.url \"file://$(pwd)/dest-c3.git\" &&\n+\tgit config set remotes.continue-group \"c1 c2 c3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for continue test\" &&\n+\n+\t# initial sync\n+\tgit push continue-group HEAD:refs/heads/main &&\n+\n+  # advance c2 independently\n+  git clone dest-c2.git tmp-c2 &&\n+  (\n+    cd tmp-c2 &&\n+    git checkout -b main origin/main &&\n+    test_commit c2_independent &&\n+    git push origin HEAD:refs/heads/main\n+  ) &&\n+  rm -rf tmp-c2 &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"local diverging commit\" &&\n+\n+\t# push: c2 rejects, others succeed\n+\ttest_must_fail git push continue-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\tgit -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&\n+\tgit -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&\n+\ttest_cmp expect actual-c1 &&\n+\ttest_cmp expect actual-c3 &&\n+\n+\t# c2 should not have the new commit\n+\tgit -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&\n+\t! test_cmp expect actual-c2\n+'\n+\n+test_expect_success 'fatal connection error stops remaining remotes' '\n+\tfor i in f1 f2 f3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.f1.url \"file://$(pwd)/dest-f1.git\" &&\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\" &&\n+\tgit config set remote.f3.url \"file://$(pwd)/dest-f3.git\" &&\n+\tgit config set remotes.fatal-group \"f1 f2 f3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for fatal test\" &&\n+\n+\t# initial sync\n+\tgit push fatal-group HEAD:refs/heads/main &&\n+\n+\t# break f2\n+\tgit config set remote.f2.url \"file:///tmp/does-not-exist-$$\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"after fatal setup\" &&\n+\n+\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n+\ttest_cmp expect actual-f1 &&\n+\n+\t# f3 should not be updated\n+\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n+\t! test_cmp expect actual-f3 &&\n+\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n+'\n+\n+test_done\n-- \n2.48.0.rc0.4242.g73eb647d24.dirty\n\n"},{"id":"539988","messageId":"xmqq7bqzu1xh.fsf@gitster.g","threadId":"65145","inReplyTo":"20260325190906.1153080-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-25T19:47:06Z","receivedAt":"2026-03-25T19:47:08Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n> `git fetch` accepts a remote group name (configured via `remotes.<name>`\n> in config) and fetches from each member remote. `git push` has no\n> equivalent — it only accepts a single remote name.\n>\n> Teach `git push` to resolve its repository argument through\n> `add_remote_or_group()`, which was made public in the previous patch,\n> so that a user can push to all remotes in a group with:\n>\n>     git push <group>\n>\n> When the argument resolves to a single remote, the behaviour is\n> identical to before. When it resolves to a group, each member remote\n> is pushed in sequence.\n>\n> The group push path rebuilds the refspec list (`rs`) from scratch for\n> each member remote so that per-remote push mappings configured via\n> `remote.<name>.push` are resolved correctly against each specific\n> remote. Without this, refspec entries would accumulate across iterations\n> and each subsequent remote would receive a growing list of duplicated\n> entries.\n>\n> Mirror detection (`remote->mirror`) is also evaluated per remote using\n> a copy of the flags, so that a mirror remote in the group cannot set\n> TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n>\n> Suggested-by: Junio C Hamano <gitster@pobox.com>\n> Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n> ---\n>  Documentation/git-push.adoc |  73 ++++++++++++++++--\n>  builtin/push.c              | 123 +++++++++++++++++++++--------\n>  t/meson.build               |   1 +\n>  t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++\n>  4 files changed, 306 insertions(+), 41 deletions(-)\n>  create mode 100755 t/t5566-push-group.sh\n\n> diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\n> index e5ba3a6742..b7f617a290 100644\n> --- a/Documentation/git-push.adoc\n> +++ b/Documentation/git-push.adoc\n> @@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n\nAll the differences since the previous iteration in the patch to\nthis file makes sense to me, except one thing.\n\n> +The behaviour upon failure depends on the kind of error encountered:\n> +\n> +If a member remote rejects the push, for example due to a\n> +non-fast-forward update, force needed but not given, an existing tag,\n> +or a server-side hook refusing a ref, Git reports the error and continues\n> +pushing to the remaining remotes in the group. The overall exit code is\n> +non-zero if any member push fails.\n> +\n> +If a member remote cannot be contacted at all, for example because the\n> +repository does not exist, authentication fails, or the network is\n> +unreachable, the push stops at that point and the remaining remotes\n> +are not attempted.\n\nI am not convinced that having these two \"failure modes\" is a good\nthing; I am not convinced that a single failure mode is better,\neither, though X-<.\n\nI would personally have designed to mimic exactly like \"git push r1;\ngit push r2; ...\" would do (not concatenated with \"&&\" but with\n\";\"), which would mean that there is only one single failure mode\nthat would not affect interactions with any other remotes, but I\nhave no strong arguments to choose that design, other than that it\nwould be easy to explain when we later start supporting pushes to\nmultiple remotes in parallel, where a failure to talk to one remote\ncannot easily affect interaction with other remotes without getting\naffected by timing issues.\n\n> +This means the user is responsible for ensuring that the sequence of\n> +individual pushes makes sense. If `git push r1`` would fail for a given\n> +set of options and arguments, then `git push all-remotes` will fail in\n> +the same way when it reaches r1. The group push does not do anything\n> +special to make a failing individual push succeed.\n\n\"when it reaches r1\" makes it sound as if the group push then stops\nafter that failure, but that is not what we just read in the two\nparagraphs about two failure modes.\n\n"},{"id":"540242","messageId":"xmqqse9kj4rh.fsf@gitster.g","threadId":"65145","inReplyTo":"20260325190906.1153080-3-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-27T22:18:10Z","receivedAt":"2026-03-27T22:18:14Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n>  t/meson.build               |   1 +\n>  t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++\n> ...\n> diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\n> new file mode 100755\n> index 0000000000..b9962946c7\n> --- /dev/null\n> +++ b/t/t5566-push-group.sh\n> @@ -0,0 +1,150 @@\n> +#!/bin/sh\n> +\n> +test_description='push to remote group'\n> +\n> +. ./test-lib.sh\n> +\n> +test_expect_success 'setup' '\n> +\tfor i in 1 2 3\n> +\tdo\n> +\t\tgit init --bare dest-$i.git &&\n> +\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n> +\t\treturn 1\n> +\tdone &&\n> +\ttest_tick &&\n> +\tgit commit --allow-empty -m \"initial\" &&\n> +\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n> +\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n> +\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n> +\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n> +\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n> +\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n> +\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n> +'\n> +\n> +test_expect_success 'push to remote group updates all members correctly' '\n> +\tgit push all-remotes HEAD:refs/heads/main &&\n> +\tgit rev-parse HEAD >expect &&\n\n\nThese tests will break rather badly at Git 3.0 boundary, because the\ndefault branch name will be 'main' beyond that point.\n\nIt can be visible in\n\n    https://github.com/git/git/actions/runs/23667958553/job/68954593675\n\ni.e., linux-breaking-changes job.\n\nI think we can squash in a futureproof fix like this one to the\npatch.\n\ndiff --git c/t/t5566-push-group.sh w/t/t5566-push-group.sh\nindex b9962946c7..32b8c82cea 100755\n--- c/t/t5566-push-group.sh\n+++ w/t/t5566-push-group.sh\n@@ -2,6 +2,9 @@\n \n test_description='push to remote group'\n \n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n . ./test-lib.sh\n \n test_expect_success 'setup' '\n\n\n"},{"id":"540577","messageId":"CAPSxiM_oSzQvH3w-vnrmGxWkXBhDy6B3JvZyJHBR1=+BMgjytg@mail.gmail.com","threadId":"65145","inReplyTo":"xmqq7bqzu1xh.fsf@gitster.g","subject":"Re: [RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-31T22:35:12Z","receivedAt":"2026-03-31T22:35:24Z","isPatch":true,"body":">\n> I am not convinced that having these two \"failure modes\" is a good\n> thing; I am not convinced that a single failure mode is better,\n> either, though X-<.\n>\n> I would personally have designed to mimic exactly like \"git push r1;\n> git push r2; ...\" would do (not concatenated with \"&&\" but with\n> \";\"), which would mean that there is only one single failure mode\n> that would not affect interactions with any other remotes, but I\n> have no strong arguments to choose that design, other than that it\n> would be easy to explain when we later start supporting pushes to\n> multiple remotes in parallel, where a failure to talk to one remote\n> cannot easily affect interaction with other remotes without getting\n> affected by timing issues.\n>\nI did not think about deciding what approach to pick out of the two,\nIt was just the default i.e the behaviour after adding the changes.\nI think it makes sense that we have one failure mode incase of multiple\nremotes in parallel.\n> > +This means the user is responsible for ensuring that the sequence of\n> > +individual pushes makes sense. If `git push r1`` would fail for a given\n> > +set of options and arguments, then `git push all-remotes` will fail in\n> > +the same way when it reaches r1. The group push does not do anything\n> > +special to make a failing individual push succeed.\n>\n> \"when it reaches r1\" makes it sound as if the group push then stops\n> after that failure, but that is not what we just read in the two\n> paragraphs about two failure modes.\nhmm, though reading the statement from the beginning makes it clear to me,\nif `git push r1` will fail, if you `r1` is part of `all-remotes`, if\nyou use `git push all-remotes`,\nthen when it reach `r1`, `r1` will also fail. I think I can make the\nwording better which should\nbe clear for parallel cases also.\n>\n"},{"id":"540592","messageId":"CAPSxiM8Nks16nJCB9N8_bi-ZmQFF71UQEzACrF+pFXKXNuVdKQ@mail.gmail.com","threadId":"65145","inReplyTo":"xmqq7bqzu1xh.fsf@gitster.g","subject":"Re: [RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-03-31T23:45:12Z","receivedAt":"2026-03-31T23:45:25Z","isPatch":true,"body":">\n> I would personally have designed to mimic exactly like \"git push r1;\n> git push r2; ...\" would do (not concatenated with \"&&\" but with\n> \";\"), which would mean that there is only one single failure mode\n> that would not affect interactions with any other remotes, but I\n> have no strong arguments to choose that design, other than that it\n> would be easy to explain when we later start supporting pushes to\n> multiple remotes in parallel, where a failure to talk to one remote\n> cannot easily affect interaction with other remotes without getting\n> affected by timing issues.\nIf we want to have one failure mode i.e continue pushing when there is\na failure,\nthen, we have to use `run_command` to spawn a child process for each\nof the push.\n\nIs there any other approach you see here ?\n"},{"id":"540654","messageId":"xmqqcy0iwrec.fsf@gitster.g","threadId":"65145","inReplyTo":"CAPSxiM8Nks16nJCB9N8_bi-ZmQFF71UQEzACrF+pFXKXNuVdKQ@mail.gmail.com","subject":"Re: [RFC PATCH v3 2/2] push: support pushing to a remote group","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-04-01T16:56:43Z","receivedAt":"2026-04-01T16:56:47Z","isPatch":true,"body":"Usman Akinyemi <usmanakinyemi202@gmail.com> writes:\n\n>>\n>> I would personally have designed to mimic exactly like \"git push r1;\n>> git push r2; ...\" would do (not concatenated with \"&&\" but with\n>> \";\"), which would mean that there is only one single failure mode\n>> that would not affect interactions with any other remotes, but I\n>> have no strong arguments to choose that design, other than that it\n>> would be easy to explain when we later start supporting pushes to\n>> multiple remotes in parallel, where a failure to talk to one remote\n>> cannot easily affect interaction with other remotes without getting\n>> affected by timing issues.\n> If we want to have one failure mode i.e continue pushing when there is\n> a failure,\n> then, we have to use `run_command` to spawn a child process for each\n> of the push.\n\nBecause you would want to avoid hitting a \"die()\" while pushing to\nthe (N-1)th remote, before you push to the Nth remote?\n\nIf there is a \"now we have attempted to push to all N remotes, and\nknow the outcome from these N attempts, summarize them and present\nthe result\" phase in the program, then you'd need to spawn sub push\nfor N times and then the primary process needs to do the summarizing.\n\nIf there isn't any such \"post push clean-up\" phase, we need N-1 sub\npushes and the last one can be done in the primary process.  If that\nis possible, that would be ideal, because it makes N==1 case the same\nas the traditional \"push to a single remote\" case.\n\n"},{"id":"542387","messageId":"20260427140530.856125-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260325190906.1153080-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v3 0/2] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-04-27T14:05:28Z","receivedAt":"2026-04-27T14:05:36Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into two patches:\n\n  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nChanges in v4:\n\n- Made the multiple push to use child process through `run_command`\n  thereby making failure to in pushing to one remote not affect the\n  others no matter the kind of failure it is.\n- Update the test and the docs to reflect the above.\n\nRange-diff v3 -> v4:\n\n1:  dd370a19e7 = 1:  20ed79546f remote: move remote group resolution to remote.c\n2:  6a7957e61c ! 2:  964694e587 push: support pushing to a remote group\n    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`\n     +behaviour is added or removed — the group is purely a shorthand for\n     +running the same push command against each member remote individually.\n     +\n    -+The behaviour upon failure depends on the kind of error encountered:\n    -+\n    -+If a member remote rejects the push, for example due to a\n    -+non-fast-forward update, force needed but not given, an existing tag,\n    -+or a server-side hook refusing a ref, Git reports the error and continues\n    -+pushing to the remaining remotes in the group. The overall exit code is\n    -+non-zero if any member push fails.\n    -+\n    -+If a member remote cannot be contacted at all, for example because the\n    -+repository does not exist, authentication fails, or the network is\n    -+unreachable, the push stops at that point and the remaining remotes\n    -+are not attempted.\n    ++When pushing to a group of more than one remote, Git spawns a separate\n    ++`git push` subprocess for each member remote in sequence.  Each subprocess\n    ++receives the same flags and refspecs as the original invocation.  This\n    ++means that per-remote push mappings configured via `remote.<name>.push`\n    ++and mirror mode (`remote.<name>.mirror`) are evaluated independently for\n    ++each remote, and a mirror remote in the group cannot affect the push\n    ++behaviour of other non-mirror remotes in the same group.\n    ++\n    ++The `--atomic` option is not supported for group pushes, because atomicity\n    ++can only be guaranteed within a single transport connection to a single\n    ++remote.  Git will refuse the invocation with an error if `--atomic` is\n    ++combined with a group name.\n    ++\n    ++If any member remote fails whether due to a push rejection (e.g. a\n    ++non-fast-forward update, a server-side hook refusing a ref) or a connection\n    ++error (e.g. the repository does not exist, authentication fails, or the\n    ++network is unreachable), Git reports the error and continues pushing to\n    ++the remaining remotes in the group.  The overall exit code is non-zero if\n    ++any member push fails.\n     +\n     +This means the user is responsible for ensuring that the sequence of\n     +individual pushes makes sense. If `git push r1`` would fail for a given\n    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`\n      \n     \n      ## builtin/push.c ##\n    +@@\n    + #include \"config.h\"\n    + #include \"environment.h\"\n    + #include \"gettext.h\"\n    ++#include \"hex.h\"\n    + #include \"refspec.h\"\n    + #include \"run-command.h\"\n    + #include \"remote.h\"\n    +@@ builtin/push.c: static int git_push_config(const char *k, const char *v,\n    + \treturn git_default_config(k, v, ctx, NULL);\n    + }\n    + \n    ++static int push_multiple(struct string_list *list,\n    ++\t\t\t const struct string_list *push_options,\n    ++\t\t\t int flags,\n    ++\t\t\t int tags,\n    ++\t\t\t const char **refspecs,\n    ++\t\t\t int refspec_nr)\n    ++{\n    ++\tint i, result = 0;\n    ++\tstruct strvec argv = STRVEC_INIT;\n    ++\n    ++\tstrvec_push(&argv, \"push\");\n    ++\n    ++\tif (flags & TRANSPORT_PUSH_FORCE)\n    ++\t\tstrvec_push(&argv, \"--force\");\n    ++\tif (flags & TRANSPORT_PUSH_DRY_RUN)\n    ++\t\tstrvec_push(&argv, \"--dry-run\");\n    ++\tif (flags & TRANSPORT_PUSH_PORCELAIN)\n    ++\t\tstrvec_push(&argv, \"--porcelain\");\n    ++\tif (flags & TRANSPORT_PUSH_PRUNE)\n    ++\t\tstrvec_push(&argv, \"--prune\");\n    ++\tif (flags & TRANSPORT_PUSH_NO_HOOK)\n    ++\t\tstrvec_push(&argv, \"--no-verify\");\n    ++\tif (flags & TRANSPORT_PUSH_FOLLOW_TAGS)\n    ++\t\tstrvec_push(&argv, \"--follow-tags\");\n    ++\tif (flags & TRANSPORT_PUSH_SET_UPSTREAM)\n    ++\t\tstrvec_push(&argv, \"--set-upstream\");\n    ++\tif (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)\n    ++\t\tstrvec_push(&argv, \"--force-if-includes\");\n    ++\tif (flags & TRANSPORT_PUSH_ALL)\n    ++\t\tstrvec_push(&argv, \"--all\");\n    ++\tif (flags & TRANSPORT_PUSH_MIRROR)\n    ++\t\tstrvec_push(&argv, \"--mirror\");\n    ++\n    ++\tif (flags & TRANSPORT_PUSH_CERT_ALWAYS)\n    ++\t\tstrvec_push(&argv, \"--signed=yes\");\n    ++\telse if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)\n    ++\t\tstrvec_push(&argv, \"--signed=if-asked\");\n    ++\tif (!thin)\n    ++\t\tstrvec_push(&argv, \"--no-thin\");\n    ++\n    ++\tif (deleterefs)\n    ++\t\tstrvec_push(&argv, \"--delete\");\n    ++\n    ++\tif (receivepack)\n    ++\t\tstrvec_pushf(&argv, \"--receive-pack=%s\", receivepack);\n    ++\tif (verbosity >= 2)\n    ++\t\tstrvec_push(&argv, \"-v\");\n    ++\tif (verbosity >= 1)\n    ++\t\tstrvec_push(&argv, \"-v\");\n    ++\telse if (verbosity < 0)\n    ++\t\tstrvec_push(&argv, \"-q\");\n    ++\tif (progress > 0)\n    ++\t\tstrvec_push(&argv, \"--progress\");\n    ++\telse if (progress == 0)\n    ++\t\tstrvec_push(&argv, \"--no-progress\");\n    ++\n    ++\tif (family == TRANSPORT_FAMILY_IPV4)\n    ++\t\tstrvec_push(&argv, \"--ipv4\");\n    ++\telse if (family == TRANSPORT_FAMILY_IPV6)\n    ++\t\tstrvec_push(&argv, \"--ipv6\");\n    ++\n    ++\tif (recurse_submodules == RECURSE_SUBMODULES_CHECK)\n    ++\t\tstrvec_push(&argv, \"--recurse-submodules=check\");\n    ++\telse if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n    ++\t\tstrvec_push(&argv, \"--recurse-submodules=on-demand\");\n    ++\telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n    ++\t\tstrvec_push(&argv, \"--recurse-submodules=only\");\n    ++\telse if (recurse_submodules == RECURSE_SUBMODULES_OFF)\n    ++\t\tstrvec_push(&argv, \"--recurse-submodules=no\");\n    ++\n    ++\n    ++\tif (tags)\n    ++\t\tstrvec_push(&argv, \"--tags\");\n    ++\n    ++\tfor (i = 0; i < push_options->nr; i++)\n    ++\t\tstrvec_pushf(&argv, \"--push-option=%s\",\n    ++\t\t\t     push_options->items[i].string);\n    ++\n    ++\tfor (i = 0; i < cas.nr; i++) {\n    ++\t\tif (cas.entry[i].use_tracking) {\n    ++\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s\",\n    ++\t\t\t\t     cas.entry[i].refname);\n    ++\t\t} else if (!is_null_oid(&cas.entry[i].expect)) {\n    ++\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s:%s\",\n    ++\t\t\t\t     cas.entry[i].refname,\n    ++\t\t\t\t     oid_to_hex(&cas.entry[i].expect));\n    ++\t\t} else {\n    ++\t\t\tstrvec_push(&argv, \"--force-with-lease\");\n    ++\t\t}\n    ++\t}\n    ++\n    ++\tfor (i = 0; i < list->nr; i++) {\n    ++\t\tconst char *name = list->items[i].string;\n    ++\t\tstruct child_process cmd = CHILD_PROCESS_INIT;\n    ++\t\tint j;\n    ++\n    ++\t\tstrvec_pushv(&cmd.args, argv.v);\n    ++\t\tstrvec_push(&cmd.args, name);\n    ++\n    ++\t\tfor (j = 0; j < refspec_nr; j++)\n    ++\t\t\tstrvec_push(&cmd.args, refspecs[j]);\n    ++\n    ++\t\tif (verbosity >= 0)\n    ++\t\t\tprintf(_(\"Pushing to %s\\n\"), name);\n    ++\n    ++\t\tcmd.git_cmd = 1;\n    ++\t\tif (run_command(&cmd)) {\n    ++\t\t\terror(_(\"could not push to %s\"), name);\n    ++\t\t\tresult = 1;\n    ++\t\t}\n    ++\t}\n    ++\n    ++\tstrvec_clear(&argv);\n    ++\treturn result;\n    ++}\n    ++\n    + int cmd_push(int argc,\n    + \t     const char **argv,\n    + \t     const char *prefix,\n     @@ builtin/push.c: int cmd_push(int argc,\n      \tint flags = 0;\n      \tint tags = 0;\n    @@ builtin/push.c: int cmd_push(int argc,\n      \t\t\tdie(_(\"push options must not have new line characters\"));\n      \n     -\trc = do_push(flags, push_options, remote);\n    -+\t/*\n    -+\t * Push to each remote in remote_group. For a plain \"git push <remote>\"\n    -+\t * or a default push, remote_group has exactly one entry and the loop\n    -+\t * runs once — there is nothing structurally special about that case.\n    -+\t * For a group, the loop runs once per member remote.\n    -+\t *\n    -+\t * Mirror detection and the --mirror/--all + refspec conflict checks\n    -+\t * are done per remote inside the loop. A remote configured with\n    -+\t * remote.NAME.mirror=true implies mirror mode for that remote only —\n    -+\t * other non-mirror remotes in the same group are unaffected.\n    -+\t *\n    -+\t * rs is rebuilt from scratch for each remote so that per-remote push\n    -+\t * mappings (remote.NAME.push config) are resolved against the correct\n    -+\t * remote. iter_flags is derived from a clean snapshot of flags taken\n    -+\t * before the loop so that a mirror remote cannot bleed\n    -+\t * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the\n    -+\t * same group.\n    -+\t */\n    -+\tbase_flags = flags;\n    -+\tfor (size_t i = 0; i < remote_group.nr; i++) {\n    -+\t\tint iter_flags = base_flags;\n    -+\t\tstruct remote *r = pushremote_get(remote_group.items[i].string);\n    -+\t\tif (!r)\n    -+\t\t\tdie(_(\"no such remote or remote group: %s\"),\n    -+\t\t\t    remote_group.items[i].string);\n    -+\n    -+\t\tif (r->mirror)\n    -+\t\t\titer_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n    -+\n    -+\t\tif (iter_flags & TRANSPORT_PUSH_ALL) {\n    -+\t\t\tif (argc >= 2)\n    -+\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n    -+\t\t}\n    -+\t\tif (iter_flags & TRANSPORT_PUSH_MIRROR) {\n    -+\t\t\tif (argc >= 2)\n    -+\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n    ++\tif (remote_group.nr == 1) {\n    ++\t\t/*\n    ++\t\t * Single remote (the common case): run do_push() directly\n    ++\t\t * in this process.  The loop runs exactly once.\n    ++\t\t *\n    ++\t\t * Mirror detection and the --mirror/--all + refspec conflict\n    ++\t\t * checks are done here.  rs is rebuilt so that per-remote push\n    ++\t\t * mappings (remote.NAME.push config) are resolved against the\n    ++\t\t * correct remote.  inner_flags is a snapshot of flags so that a\n    ++\t\t * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any\n    ++\t\t * subsequent call.\n    ++\t\t */\n    ++\t\tbase_flags = flags;\n    ++\t\t{\n    ++\t\t\tint inner_flags = base_flags;\n    ++\t\t\tstruct remote *r = pushremote_get(remote_group.items[0].string);\n    ++\t\t\tif (!r)\n    ++\t\t\t\tdie(_(\"no such remote or remote group: %s\"),\n    ++\t\t\t\t    remote_group.items[0].string);\n    ++\n    ++\t\t\tif (r->mirror)\n    ++\t\t\t\tinner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n    ++\n    ++\t\t\tif (inner_flags & TRANSPORT_PUSH_ALL) {\n    ++\t\t\t\tif (argc >= 2)\n    ++\t\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n    ++\t\t\t}\n    ++\t\t\tif (inner_flags & TRANSPORT_PUSH_MIRROR) {\n    ++\t\t\t\tif (argc >= 2)\n    ++\t\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n    ++\t\t\t}\n    ++\n    ++\t\t\trefspec_clear(&rs);\n    ++\t\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n    ++\n    ++\t\t\tif (tags)\n    ++\t\t\t\trefspec_append(&rs, \"refs/tags/*\");\n    ++\t\t\tif (argc > 0)\n    ++\t\t\t\tset_refspecs(argv + 1, argc - 1, r);\n    ++\n    ++\t\t\trc = do_push(inner_flags, push_options, r);\n     +\t\t}\n    -+\n    -+\t\trefspec_clear(&rs);\n    -+\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n    -+\n    -+\t\tif (tags)\n    -+\t\t\trefspec_append(&rs, \"refs/tags/*\");\n    -+\t\tif (argc > 0)\n    -+\t\t\tset_refspecs(argv + 1, argc - 1, r);\n    -+\n    -+\t\trc |= do_push(iter_flags, push_options, r);\n    ++\t} else {\n    ++\t\t/*\n    ++\t\t * Multiple remotes: spawn one \"git push <remote> [<refspecs>]\"\n    ++\t\t * subprocess per remote, sequentially.\n    ++\t\t *\n    ++\t\t * Options that only make sense for a single transport connection\n    ++\t\t * are rejected here.\n    ++\t\t */\n    ++\t\tif (flags & TRANSPORT_PUSH_ATOMIC)\n    ++\t\t\tdie(_(\"--atomic can only be used when pushing to one remote\"));\n    ++\n    ++\t\trc = push_multiple(&remote_group, push_options, flags,\n    ++\t\t\t\t   tags,\n    ++\t\t\t\t   argc > 1 ? argv + 1 : NULL,\n    ++\t\t\t\t   argc > 1 ? argc - 1 : 0);\n     +\t}\n     +\n      \tstring_list_clear(&push_options_cmdline, 0);\n    @@ t/t5566-push-group.sh (new)\n     +\n     +test_description='push to remote group'\n     +\n    ++GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default\n    ++export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n    ++\n     +. ./test-lib.sh\n     +\n     +test_expect_success 'setup' '\n     +\tfor i in 1 2 3\n     +\tdo\n    -+\t\tgit init --bare dest-$i.git || return 1\n    ++\t\tgit init --bare dest-$i.git &&\n    ++\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n    ++\t\treturn 1\n     +\tdone &&\n     +\ttest_tick &&\n     +\tgit commit --allow-empty -m \"initial\" &&\n    @@ t/t5566-push-group.sh (new)\n     +\ttest_grep \"mirror\" err &&\n     +\tgit config unset remote.remote-1.mirror\n     +'\n    ++\n     +test_expect_success 'push.default=current works with group push' '\n     +\tgit config set push.default current &&\n     +\ttest_tick &&\n    @@ t/t5566-push-group.sh (new)\n     +\tgit config unset push.default\n     +'\n     +\n    ++test_expect_success '--atomic is rejected for group push' '\n    ++\ttest_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&\n    ++\ttest_grep \"atomic\" err\n    ++'\n    ++\n     +test_expect_success 'push continues past rejection to remaining remotes' '\n     +\tfor i in c1 c2 c3\n     +\tdo\n    @@ t/t5566-push-group.sh (new)\n     +\t# initial sync\n     +\tgit push continue-group HEAD:refs/heads/main &&\n     +\n    -+  # advance c2 independently\n    -+  git clone dest-c2.git tmp-c2 &&\n    -+  (\n    -+    cd tmp-c2 &&\n    -+    git checkout -b main origin/main &&\n    -+    test_commit c2_independent &&\n    -+    git push origin HEAD:refs/heads/main\n    -+  ) &&\n    -+  rm -rf tmp-c2 &&\n    ++\t# advance c2 independently\n    ++\tgit clone dest-c2.git tmp-c2 &&\n    ++\t(\n    ++\t\tcd tmp-c2 &&\n    ++\t\tgit checkout -b main origin/main &&\n    ++\t\ttest_commit c2_independent &&\n    ++\t\tgit push origin HEAD:refs/heads/main\n    ++\t) &&\n    ++\trm -rf tmp-c2 &&\n     +\n     +\ttest_tick &&\n     +\tgit commit --allow-empty -m \"local diverging commit\" &&\n    @@ t/t5566-push-group.sh (new)\n     +\t! test_cmp expect actual-c2\n     +'\n     +\n    -+test_expect_success 'fatal connection error stops remaining remotes' '\n    ++test_expect_success 'fatal connection error does not stop remaining remotes' '\n     +\tfor i in f1 f2 f3\n     +\tdo\n     +\t\tgit init --bare dest-$i.git || return 1\n    @@ t/t5566-push-group.sh (new)\n     +\ttest_tick &&\n     +\tgit commit --allow-empty -m \"after fatal setup\" &&\n     +\n    ++\t# overall exit code is non-zero because f2 failed\n     +\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n     +\n     +\tgit rev-parse HEAD >expect &&\n    ++\n    ++\t# f1 and f3 should both have the new commit — subprocesses are independent\n     +\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n     +\ttest_cmp expect actual-f1 &&\n    -+\n    -+\t# f3 should not be updated\n     +\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n    -+\t! test_cmp expect actual-f3 &&\n    ++\ttest_cmp expect actual-f3 &&\n     +\n     +\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n     +'\n\nUsman Akinyemi (2):\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/fetch.c             |  42 ------\n builtin/push.c              | 250 +++++++++++++++++++++++++++++++-----\n remote.c                    |  37 ++++++\n remote.h                    |  12 ++\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 7 files changed, 499 insertions(+), 83 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\n-- \n2.53.0\n\n"},{"id":"542388","messageId":"20260427140530.856125-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260427140530.856125-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v4 1/2] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-04-27T14:05:29Z","receivedAt":"2026-04-27T14:05:39Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..cfb26eb284 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex a664cd166a..7133d29332 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..8ff2bd88fa 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+                    const struct config_context *ctx,\n+                    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.53.0\n\n"},{"id":"542389","messageId":"20260427140530.856125-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260427140530.856125-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v4 2/2] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-04-27T14:05:30Z","receivedAt":"2026-04-27T14:05:42Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote, the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/push.c              | 250 +++++++++++++++++++++++++++++++-----\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 4 files changed, 450 insertions(+), 41 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\ndiff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\nindex e5ba3a6742..aa221c3909 100644\n--- a/Documentation/git-push.adoc\n+++ b/Documentation/git-push.adoc\n@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n \n DESCRIPTION\n -----------\n-\n-Updates one or more branches, tags, or other references in a remote\n-repository from your local repository, and sends all necessary data\n-that isn't already on the remote.\n+Updates one or more branches, tags, or other references in one or more\n+remote repositories from your local repository, and sends all necessary\n+data that isn't already on the remote.\n \n The simplest way to push is `git push <remote> <branch>`.\n `git push origin main` will push the local `main` branch to the `main`\n branch on the remote named `origin`.\n \n-The `<repository>` argument defaults to the upstream for the current branch,\n-or `origin` if there's no configured upstream.\n+You can also push to multiple remotes at once by using a remote group.\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"origin gitlab backup\"\n+\n+Then `git push all-remotes` will push to `origin`, `gitlab`, and\n+`backup` in turn, as if you had run `git push` against each one\n+individually.  Each remote is pushed independently using its own\n+push mapping configuration. There is a `remotes.<group>` entry in\n+the configuration file. (See linkgit:git-config[1]).\n+\n+The `<repository>` argument defaults to the upstream for the current\n+branch, or `origin` if there's no configured upstream.\n \n To decide which branches, tags, or other refs to push, Git uses\n (in order of precedence):\n@@ -55,8 +66,10 @@ OPTIONS\n _<repository>_::\n \tThe \"remote\" repository that is the destination of a push\n \toperation.  This parameter can be either a URL\n-\t(see the section <<URLS,GIT URLS>> below) or the name\n-\tof a remote (see the section <<REMOTES,REMOTES>> below).\n+\t(see the section <<URLS,GIT URLS>> below), the name\n+\tof a remote (see the section <<REMOTES,REMOTES>> below),\n+\tor the name of a remote group\n+\t(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).\n \n `<refspec>...`::\n \tSpecify what destination ref to update with what source object.\n@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.\n \n include::urls-remotes.adoc[]\n \n+[[REMOTE-GROUPS]]\n+REMOTE GROUPS\n+-------------\n+\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"r1 r2 r3\"\n+\n+When a group name is given as the `<repository>` argument, the push is\n+performed to each member remote in turn.  The defining principle is:\n+\n+\tgit push <options> all-remotes <args>\n+\n+is exactly equivalent to:\n+\n+\tgit push <options> r1 <args>\n+\tgit push <options> r2 <args>\n+\t...\n+\tgit push <options> rN <args>\n+\n+where r1, r2, ..., rN are the members of `all-remotes`.  No special\n+behaviour is added or removed — the group is purely a shorthand for\n+running the same push command against each member remote individually.\n+\n+When pushing to a group of more than one remote, Git spawns a separate\n+`git push` subprocess for each member remote in sequence.  Each subprocess\n+receives the same flags and refspecs as the original invocation.  This\n+means that per-remote push mappings configured via `remote.<name>.push`\n+and mirror mode (`remote.<name>.mirror`) are evaluated independently for\n+each remote, and a mirror remote in the group cannot affect the push\n+behaviour of other non-mirror remotes in the same group.\n+\n+The `--atomic` option is not supported for group pushes, because atomicity\n+can only be guaranteed within a single transport connection to a single\n+remote.  Git will refuse the invocation with an error if `--atomic` is\n+combined with a group name.\n+\n+If any member remote fails whether due to a push rejection (e.g. a\n+non-fast-forward update, a server-side hook refusing a ref) or a connection\n+error (e.g. the repository does not exist, authentication fails, or the\n+network is unreachable), Git reports the error and continues pushing to\n+the remaining remotes in the group.  The overall exit code is non-zero if\n+any member push fails.\n+\n+This means the user is responsible for ensuring that the sequence of\n+individual pushes makes sense. If `git push r1`` would fail for a given\n+set of options and arguments, then `git push all-remotes` will fail in\n+the same way when it reaches r1. The group push does not do anything\n+special to make a failing individual push succeed.\n+\n OUTPUT\n ------\n \ndiff --git a/builtin/push.c b/builtin/push.c\nindex 7100ffba5d..10384f265c 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -10,6 +10,7 @@\n #include \"config.h\"\n #include \"environment.h\"\n #include \"gettext.h\"\n+#include \"hex.h\"\n #include \"refspec.h\"\n #include \"run-command.h\"\n #include \"remote.h\"\n@@ -544,6 +545,122 @@ static int git_push_config(const char *k, const char *v,\n \treturn git_default_config(k, v, ctx, NULL);\n }\n \n+static int push_multiple(struct string_list *list,\n+\t\t\t const struct string_list *push_options,\n+\t\t\t int flags,\n+\t\t\t int tags,\n+\t\t\t const char **refspecs,\n+\t\t\t int refspec_nr)\n+{\n+\tint i, result = 0;\n+\tstruct strvec argv = STRVEC_INIT;\n+\n+\tstrvec_push(&argv, \"push\");\n+\n+\tif (flags & TRANSPORT_PUSH_FORCE)\n+\t\tstrvec_push(&argv, \"--force\");\n+\tif (flags & TRANSPORT_PUSH_DRY_RUN)\n+\t\tstrvec_push(&argv, \"--dry-run\");\n+\tif (flags & TRANSPORT_PUSH_PORCELAIN)\n+\t\tstrvec_push(&argv, \"--porcelain\");\n+\tif (flags & TRANSPORT_PUSH_PRUNE)\n+\t\tstrvec_push(&argv, \"--prune\");\n+\tif (flags & TRANSPORT_PUSH_NO_HOOK)\n+\t\tstrvec_push(&argv, \"--no-verify\");\n+\tif (flags & TRANSPORT_PUSH_FOLLOW_TAGS)\n+\t\tstrvec_push(&argv, \"--follow-tags\");\n+\tif (flags & TRANSPORT_PUSH_SET_UPSTREAM)\n+\t\tstrvec_push(&argv, \"--set-upstream\");\n+\tif (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)\n+\t\tstrvec_push(&argv, \"--force-if-includes\");\n+\tif (flags & TRANSPORT_PUSH_ALL)\n+\t\tstrvec_push(&argv, \"--all\");\n+\tif (flags & TRANSPORT_PUSH_MIRROR)\n+\t\tstrvec_push(&argv, \"--mirror\");\n+\n+\tif (flags & TRANSPORT_PUSH_CERT_ALWAYS)\n+\t\tstrvec_push(&argv, \"--signed=yes\");\n+\telse if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)\n+\t\tstrvec_push(&argv, \"--signed=if-asked\");\n+\tif (!thin)\n+\t\tstrvec_push(&argv, \"--no-thin\");\n+\n+\tif (deleterefs)\n+\t\tstrvec_push(&argv, \"--delete\");\n+\n+\tif (receivepack)\n+\t\tstrvec_pushf(&argv, \"--receive-pack=%s\", receivepack);\n+\tif (verbosity >= 2)\n+\t\tstrvec_push(&argv, \"-v\");\n+\tif (verbosity >= 1)\n+\t\tstrvec_push(&argv, \"-v\");\n+\telse if (verbosity < 0)\n+\t\tstrvec_push(&argv, \"-q\");\n+\tif (progress > 0)\n+\t\tstrvec_push(&argv, \"--progress\");\n+\telse if (progress == 0)\n+\t\tstrvec_push(&argv, \"--no-progress\");\n+\n+\tif (family == TRANSPORT_FAMILY_IPV4)\n+\t\tstrvec_push(&argv, \"--ipv4\");\n+\telse if (family == TRANSPORT_FAMILY_IPV6)\n+\t\tstrvec_push(&argv, \"--ipv6\");\n+\n+\tif (recurse_submodules == RECURSE_SUBMODULES_CHECK)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=check\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=on-demand\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=only\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_OFF)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=no\");\n+\n+\n+\tif (tags)\n+\t\tstrvec_push(&argv, \"--tags\");\n+\n+\tfor (i = 0; i < push_options->nr; i++)\n+\t\tstrvec_pushf(&argv, \"--push-option=%s\",\n+\t\t\t     push_options->items[i].string);\n+\n+\tfor (i = 0; i < cas.nr; i++) {\n+\t\tif (cas.entry[i].use_tracking) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s\",\n+\t\t\t\t     cas.entry[i].refname);\n+\t\t} else if (!is_null_oid(&cas.entry[i].expect)) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s:%s\",\n+\t\t\t\t     cas.entry[i].refname,\n+\t\t\t\t     oid_to_hex(&cas.entry[i].expect));\n+\t\t} else {\n+\t\t\tstrvec_push(&argv, \"--force-with-lease\");\n+\t\t}\n+\t}\n+\n+\tfor (i = 0; i < list->nr; i++) {\n+\t\tconst char *name = list->items[i].string;\n+\t\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\t\tint j;\n+\n+\t\tstrvec_pushv(&cmd.args, argv.v);\n+\t\tstrvec_push(&cmd.args, name);\n+\n+\t\tfor (j = 0; j < refspec_nr; j++)\n+\t\t\tstrvec_push(&cmd.args, refspecs[j]);\n+\n+\t\tif (verbosity >= 0)\n+\t\t\tprintf(_(\"Pushing to %s\\n\"), name);\n+\n+\t\tcmd.git_cmd = 1;\n+\t\tif (run_command(&cmd)) {\n+\t\t\terror(_(\"could not push to %s\"), name);\n+\t\t\tresult = 1;\n+\t\t}\n+\t}\n+\n+\tstrvec_clear(&argv);\n+\treturn result;\n+}\n+\n int cmd_push(int argc,\n \t     const char **argv,\n \t     const char *prefix,\n@@ -552,12 +669,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n+\tint base_flags;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP;\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -620,39 +738,45 @@ int cmd_push(int argc,\n \telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n \t\tflags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;\n \n-\tif (tags)\n-\t\trefspec_append(&rs, \"refs/tags/*\");\n-\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n-\t}\n-\n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n-\n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n-\n-\tif (flags & TRANSPORT_PUSH_ALL) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n-\t}\n-\tif (flags & TRANSPORT_PUSH_MIRROR) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group)) {\n+\t\t\t/*\n+\t\t\t * Not a configured remote name or group name.\n+\t\t\t * Try treating it as a direct URL or path, e.g.\n+\t\t\t *   git push /tmp/foo.git\n+\t\t\t *   git push https://github.com/user/repo.git\n+\t\t\t * pushremote_get() creates an anonymous remote\n+\t\t\t * from the URL so the loop below can handle it\n+\t\t\t * identically to a named remote.\n+\t\t\t */\n+\t\t\tstruct remote *r = pushremote_get(repo);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"bad repository '%s'\"), repo);\n+\t\t\tstring_list_append(&remote_group, r->name);\n+\t\t}\n+\t} else {\n+\t\tstruct remote *r = pushremote_get(NULL);\n+\t\tif (!r)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"To push to multiple remotes at once, configure a remote group using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git config remotes.<groupname> \\\"<remote1> <remote2>\\\"\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the group name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <groupname>\\n\"));\n+\t\tstring_list_append(&remote_group, r->name);\n \t}\n \n \tif (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))\n@@ -662,10 +786,70 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\tif (remote_group.nr == 1) {\n+\t\t/*\n+\t\t * Single remote (the common case): run do_push() directly\n+\t\t * in this process.  The loop runs exactly once.\n+\t\t *\n+\t\t * Mirror detection and the --mirror/--all + refspec conflict\n+\t\t * checks are done here.  rs is rebuilt so that per-remote push\n+\t\t * mappings (remote.NAME.push config) are resolved against the\n+\t\t * correct remote.  inner_flags is a snapshot of flags so that a\n+\t\t * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any\n+\t\t * subsequent call.\n+\t\t */\n+\t\tbase_flags = flags;\n+\t\t{\n+\t\t\tint inner_flags = base_flags;\n+\t\t\tstruct remote *r = pushremote_get(remote_group.items[0].string);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t\t    remote_group.items[0].string);\n+\n+\t\t\tif (r->mirror)\n+\t\t\t\tinner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_ALL) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n+\t\t\t}\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_MIRROR) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\t\t\t}\n+\n+\t\t\trefspec_clear(&rs);\n+\t\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\t\tif (tags)\n+\t\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\t\tif (argc > 0)\n+\t\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\t\trc = do_push(inner_flags, push_options, r);\n+\t\t}\n+\t} else {\n+\t\t/*\n+\t\t * Multiple remotes: spawn one \"git push <remote> [<refspecs>]\"\n+\t\t * subprocess per remote, sequentially.\n+\t\t *\n+\t\t * Options that only make sense for a single transport connection\n+\t\t * are rejected here.\n+\t\t */\n+\t\tif (flags & TRANSPORT_PUSH_ATOMIC)\n+\t\t\tdie(_(\"--atomic can only be used when pushing to one remote\"));\n+\n+\t\trc = push_multiple(&remote_group, push_options, flags,\n+\t\t\t\t   tags,\n+\t\t\t\t   argc > 1 ? argv + 1 : NULL,\n+\t\t\t\t   argc > 1 ? argc - 1 : 0);\n+\t}\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..bd090627e9 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -704,6 +704,7 @@ integration_tests = [\n   't5563-simple-http-auth.sh',\n   't5564-http-proxy.sh',\n   't5565-push-multiple.sh',\n+  't5566-push-group.sh',\n   't5570-git-daemon.sh',\n   't5571-pre-push-hook.sh',\n   't5572-pull-submodule.sh',\ndiff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\nnew file mode 100755\nindex 0000000000..a7d59352b1\n--- /dev/null\n+++ b/t/t5566-push-group.sh\n@@ -0,0 +1,160 @@\n+#!/bin/sh\n+\n+test_description='push to remote group'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit init --bare dest-$i.git &&\n+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"initial\" &&\n+\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n+\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n+\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n+\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n+\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n+\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n+\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n+'\n+\n+test_expect_success 'push to remote group updates all members correctly' '\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push second commit to group updates all members' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"second\" &&\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push to single remote in group does not affect others' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"third\" &&\n+\tgit push remote-1 HEAD:refs/heads/main &&\n+\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n+\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n+\t! test_cmp hash-after-1 hash-after-2\n+'\n+\n+test_expect_success 'mirror remote in group with refspec fails' '\n+\tgit config set remote.remote-1.mirror true &&\n+\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"mirror\" err &&\n+\tgit config unset remote.remote-1.mirror\n+'\n+\n+test_expect_success 'push.default=current works with group push' '\n+\tgit config set push.default current &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fifth\" &&\n+\tgit push all-remotes &&\n+\tgit config unset push.default\n+'\n+\n+test_expect_success '--atomic is rejected for group push' '\n+\ttest_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"atomic\" err\n+'\n+\n+test_expect_success 'push continues past rejection to remaining remotes' '\n+\tfor i in c1 c2 c3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.c1.url \"file://$(pwd)/dest-c1.git\" &&\n+\tgit config set remote.c2.url \"file://$(pwd)/dest-c2.git\" &&\n+\tgit config set remote.c3.url \"file://$(pwd)/dest-c3.git\" &&\n+\tgit config set remotes.continue-group \"c1 c2 c3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for continue test\" &&\n+\n+\t# initial sync\n+\tgit push continue-group HEAD:refs/heads/main &&\n+\n+\t# advance c2 independently\n+\tgit clone dest-c2.git tmp-c2 &&\n+\t(\n+\t\tcd tmp-c2 &&\n+\t\tgit checkout -b main origin/main &&\n+\t\ttest_commit c2_independent &&\n+\t\tgit push origin HEAD:refs/heads/main\n+\t) &&\n+\trm -rf tmp-c2 &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"local diverging commit\" &&\n+\n+\t# push: c2 rejects, others succeed\n+\ttest_must_fail git push continue-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\tgit -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&\n+\tgit -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&\n+\ttest_cmp expect actual-c1 &&\n+\ttest_cmp expect actual-c3 &&\n+\n+\t# c2 should not have the new commit\n+\tgit -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&\n+\t! test_cmp expect actual-c2\n+'\n+\n+test_expect_success 'fatal connection error does not stop remaining remotes' '\n+\tfor i in f1 f2 f3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.f1.url \"file://$(pwd)/dest-f1.git\" &&\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\" &&\n+\tgit config set remote.f3.url \"file://$(pwd)/dest-f3.git\" &&\n+\tgit config set remotes.fatal-group \"f1 f2 f3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for fatal test\" &&\n+\n+\t# initial sync\n+\tgit push fatal-group HEAD:refs/heads/main &&\n+\n+\t# break f2\n+\tgit config set remote.f2.url \"file:///tmp/does-not-exist-$$\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"after fatal setup\" &&\n+\n+\t# overall exit code is non-zero because f2 failed\n+\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\n+\t# f1 and f3 should both have the new commit — subprocesses are independent\n+\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n+\ttest_cmp expect actual-f1 &&\n+\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n+\ttest_cmp expect actual-f3 &&\n+\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n+'\n+\n+test_done\n-- \n2.53.0\n\n"},{"id":"542407","messageId":"xmqq340f26vx.fsf@gitster.g","threadId":"65145","inReplyTo":"20260427140530.856125-1-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v3 0/2] push: add support for pushing to remote groups","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-04-28T01:47:14Z","receivedAt":"2026-04-28T01:47:16Z","isPatch":true,"body":"Something like this is needed to workaround -Werror=sign-compare complaints.\n\ndiff --git a/builtin/push.c b/builtin/push.c\nindex 10384f265c..6021b71d66 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -552,7 +552,8 @@ static int push_multiple(struct string_list *list,\n \t\t\t const char **refspecs,\n \t\t\t int refspec_nr)\n {\n-\tint i, result = 0;\n+\tint result = 0;\n+\tsize_t i;\n \tstruct strvec argv = STRVEC_INIT;\n \n \tstrvec_push(&argv, \"push\");\ndiff --git a/remote.h b/remote.h\nindex 8ff2bd88fa..7915be3111 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -430,8 +430,8 @@ struct push_cas_option {\n \t\tunsigned use_tracking:1;\n \t\tchar *refname;\n \t} *entry;\n-\tint nr;\n-\tint alloc;\n+\tsize_t nr;\n+\tsize_t alloc;\n };\n \n int parseopt_push_cas_option(const struct option *, const char *arg, int unset);\n"},{"id":"542613","messageId":"20260503153402.1333220-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260427140530.856125-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v5 0/3] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-03T15:33:59Z","receivedAt":"2026-05-03T15:34:10Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into two patches:\n\n  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nChanges in v5:\n- fixed the -Werror=sign-compare in builtin/push.c complaints and also added\n  a new commit that fix the remote.h\n\nRange-diff v4 -> v5:\n-:  ---------- > 1:  e01126890c remote: fix sign-compare warnings in push_cas_option\n1:  20ed79546f = 2:  adbce652e6 remote: move remote group resolution to remote.c\n2:  964694e587 ! 3:  a8d5f4b7bd push: support pushing to a remote group\n    @@ builtin/push.c: static int git_push_config(const char *k, const char *v,\n     +\t\t\t const char **refspecs,\n     +\t\t\t int refspec_nr)\n     +{\n    -+\tint i, result = 0;\n    ++\tint result = 0;\n    ++\tsize_t i;\n     +\tstruct strvec argv = STRVEC_INIT;\n     +\n     +\tstrvec_push(&argv, \"push\");\n\nUsman Akinyemi (3):\n  remote: fix sign-compare warnings in push_cas_option\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/fetch.c             |  42 ------\n builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----\n remote.c                    |  37 ++++++\n remote.h                    |  16 ++-\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 7 files changed, 502 insertions(+), 85 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\n-- \n2.53.0\n\n"},{"id":"542614","messageId":"20260503153402.1333220-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260503153402.1333220-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v5 1/3] remote: fix sign-compare warnings in push_cas_option","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-03T15:34:00Z","receivedAt":"2026-05-03T15:34:14Z","isPatch":true,"body":"Replace `int` with `size_t` for `nr` and `alloc` in\n`struct push_cas_option` to avoid -Werror=sign-compare\nwarnings when comparing against size-based values.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n remote.h | 4 ++--\n 1 file changed, 2 insertions(+), 2 deletions(-)\n\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..741d14a9fc 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -418,8 +418,8 @@ struct push_cas_option {\n \t\tunsigned use_tracking:1;\n \t\tchar *refname;\n \t} *entry;\n-\tint nr;\n-\tint alloc;\n+\tsize_t nr;\n+\tsize_t alloc;\n };\n \n int parseopt_push_cas_option(const struct option *, const char *arg, int unset);\n-- \n2.53.0\n\n"},{"id":"542615","messageId":"20260503153402.1333220-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260503153402.1333220-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v5 2/3] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-03T15:34:01Z","receivedAt":"2026-05-03T15:34:17Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..cfb26eb284 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex a664cd166a..7133d29332 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex 741d14a9fc..7915be3111 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+                    const struct config_context *ctx,\n+                    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.53.0\n\n"},{"id":"542616","messageId":"20260503153402.1333220-4-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260503153402.1333220-1-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v5 3/3] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-03T15:34:02Z","receivedAt":"2026-05-03T15:34:23Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote, the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 4 files changed, 451 insertions(+), 41 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\ndiff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\nindex e5ba3a6742..aa221c3909 100644\n--- a/Documentation/git-push.adoc\n+++ b/Documentation/git-push.adoc\n@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n \n DESCRIPTION\n -----------\n-\n-Updates one or more branches, tags, or other references in a remote\n-repository from your local repository, and sends all necessary data\n-that isn't already on the remote.\n+Updates one or more branches, tags, or other references in one or more\n+remote repositories from your local repository, and sends all necessary\n+data that isn't already on the remote.\n \n The simplest way to push is `git push <remote> <branch>`.\n `git push origin main` will push the local `main` branch to the `main`\n branch on the remote named `origin`.\n \n-The `<repository>` argument defaults to the upstream for the current branch,\n-or `origin` if there's no configured upstream.\n+You can also push to multiple remotes at once by using a remote group.\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"origin gitlab backup\"\n+\n+Then `git push all-remotes` will push to `origin`, `gitlab`, and\n+`backup` in turn, as if you had run `git push` against each one\n+individually.  Each remote is pushed independently using its own\n+push mapping configuration. There is a `remotes.<group>` entry in\n+the configuration file. (See linkgit:git-config[1]).\n+\n+The `<repository>` argument defaults to the upstream for the current\n+branch, or `origin` if there's no configured upstream.\n \n To decide which branches, tags, or other refs to push, Git uses\n (in order of precedence):\n@@ -55,8 +66,10 @@ OPTIONS\n _<repository>_::\n \tThe \"remote\" repository that is the destination of a push\n \toperation.  This parameter can be either a URL\n-\t(see the section <<URLS,GIT URLS>> below) or the name\n-\tof a remote (see the section <<REMOTES,REMOTES>> below).\n+\t(see the section <<URLS,GIT URLS>> below), the name\n+\tof a remote (see the section <<REMOTES,REMOTES>> below),\n+\tor the name of a remote group\n+\t(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).\n \n `<refspec>...`::\n \tSpecify what destination ref to update with what source object.\n@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.\n \n include::urls-remotes.adoc[]\n \n+[[REMOTE-GROUPS]]\n+REMOTE GROUPS\n+-------------\n+\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"r1 r2 r3\"\n+\n+When a group name is given as the `<repository>` argument, the push is\n+performed to each member remote in turn.  The defining principle is:\n+\n+\tgit push <options> all-remotes <args>\n+\n+is exactly equivalent to:\n+\n+\tgit push <options> r1 <args>\n+\tgit push <options> r2 <args>\n+\t...\n+\tgit push <options> rN <args>\n+\n+where r1, r2, ..., rN are the members of `all-remotes`.  No special\n+behaviour is added or removed — the group is purely a shorthand for\n+running the same push command against each member remote individually.\n+\n+When pushing to a group of more than one remote, Git spawns a separate\n+`git push` subprocess for each member remote in sequence.  Each subprocess\n+receives the same flags and refspecs as the original invocation.  This\n+means that per-remote push mappings configured via `remote.<name>.push`\n+and mirror mode (`remote.<name>.mirror`) are evaluated independently for\n+each remote, and a mirror remote in the group cannot affect the push\n+behaviour of other non-mirror remotes in the same group.\n+\n+The `--atomic` option is not supported for group pushes, because atomicity\n+can only be guaranteed within a single transport connection to a single\n+remote.  Git will refuse the invocation with an error if `--atomic` is\n+combined with a group name.\n+\n+If any member remote fails whether due to a push rejection (e.g. a\n+non-fast-forward update, a server-side hook refusing a ref) or a connection\n+error (e.g. the repository does not exist, authentication fails, or the\n+network is unreachable), Git reports the error and continues pushing to\n+the remaining remotes in the group.  The overall exit code is non-zero if\n+any member push fails.\n+\n+This means the user is responsible for ensuring that the sequence of\n+individual pushes makes sense. If `git push r1`` would fail for a given\n+set of options and arguments, then `git push all-remotes` will fail in\n+the same way when it reaches r1. The group push does not do anything\n+special to make a failing individual push succeed.\n+\n OUTPUT\n ------\n \ndiff --git a/builtin/push.c b/builtin/push.c\nindex 7100ffba5d..6021b71d66 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -10,6 +10,7 @@\n #include \"config.h\"\n #include \"environment.h\"\n #include \"gettext.h\"\n+#include \"hex.h\"\n #include \"refspec.h\"\n #include \"run-command.h\"\n #include \"remote.h\"\n@@ -544,6 +545,123 @@ static int git_push_config(const char *k, const char *v,\n \treturn git_default_config(k, v, ctx, NULL);\n }\n \n+static int push_multiple(struct string_list *list,\n+\t\t\t const struct string_list *push_options,\n+\t\t\t int flags,\n+\t\t\t int tags,\n+\t\t\t const char **refspecs,\n+\t\t\t int refspec_nr)\n+{\n+\tint result = 0;\n+\tsize_t i;\n+\tstruct strvec argv = STRVEC_INIT;\n+\n+\tstrvec_push(&argv, \"push\");\n+\n+\tif (flags & TRANSPORT_PUSH_FORCE)\n+\t\tstrvec_push(&argv, \"--force\");\n+\tif (flags & TRANSPORT_PUSH_DRY_RUN)\n+\t\tstrvec_push(&argv, \"--dry-run\");\n+\tif (flags & TRANSPORT_PUSH_PORCELAIN)\n+\t\tstrvec_push(&argv, \"--porcelain\");\n+\tif (flags & TRANSPORT_PUSH_PRUNE)\n+\t\tstrvec_push(&argv, \"--prune\");\n+\tif (flags & TRANSPORT_PUSH_NO_HOOK)\n+\t\tstrvec_push(&argv, \"--no-verify\");\n+\tif (flags & TRANSPORT_PUSH_FOLLOW_TAGS)\n+\t\tstrvec_push(&argv, \"--follow-tags\");\n+\tif (flags & TRANSPORT_PUSH_SET_UPSTREAM)\n+\t\tstrvec_push(&argv, \"--set-upstream\");\n+\tif (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)\n+\t\tstrvec_push(&argv, \"--force-if-includes\");\n+\tif (flags & TRANSPORT_PUSH_ALL)\n+\t\tstrvec_push(&argv, \"--all\");\n+\tif (flags & TRANSPORT_PUSH_MIRROR)\n+\t\tstrvec_push(&argv, \"--mirror\");\n+\n+\tif (flags & TRANSPORT_PUSH_CERT_ALWAYS)\n+\t\tstrvec_push(&argv, \"--signed=yes\");\n+\telse if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)\n+\t\tstrvec_push(&argv, \"--signed=if-asked\");\n+\tif (!thin)\n+\t\tstrvec_push(&argv, \"--no-thin\");\n+\n+\tif (deleterefs)\n+\t\tstrvec_push(&argv, \"--delete\");\n+\n+\tif (receivepack)\n+\t\tstrvec_pushf(&argv, \"--receive-pack=%s\", receivepack);\n+\tif (verbosity >= 2)\n+\t\tstrvec_push(&argv, \"-v\");\n+\tif (verbosity >= 1)\n+\t\tstrvec_push(&argv, \"-v\");\n+\telse if (verbosity < 0)\n+\t\tstrvec_push(&argv, \"-q\");\n+\tif (progress > 0)\n+\t\tstrvec_push(&argv, \"--progress\");\n+\telse if (progress == 0)\n+\t\tstrvec_push(&argv, \"--no-progress\");\n+\n+\tif (family == TRANSPORT_FAMILY_IPV4)\n+\t\tstrvec_push(&argv, \"--ipv4\");\n+\telse if (family == TRANSPORT_FAMILY_IPV6)\n+\t\tstrvec_push(&argv, \"--ipv6\");\n+\n+\tif (recurse_submodules == RECURSE_SUBMODULES_CHECK)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=check\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=on-demand\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=only\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_OFF)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=no\");\n+\n+\n+\tif (tags)\n+\t\tstrvec_push(&argv, \"--tags\");\n+\n+\tfor (i = 0; i < push_options->nr; i++)\n+\t\tstrvec_pushf(&argv, \"--push-option=%s\",\n+\t\t\t     push_options->items[i].string);\n+\n+\tfor (i = 0; i < cas.nr; i++) {\n+\t\tif (cas.entry[i].use_tracking) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s\",\n+\t\t\t\t     cas.entry[i].refname);\n+\t\t} else if (!is_null_oid(&cas.entry[i].expect)) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s:%s\",\n+\t\t\t\t     cas.entry[i].refname,\n+\t\t\t\t     oid_to_hex(&cas.entry[i].expect));\n+\t\t} else {\n+\t\t\tstrvec_push(&argv, \"--force-with-lease\");\n+\t\t}\n+\t}\n+\n+\tfor (i = 0; i < list->nr; i++) {\n+\t\tconst char *name = list->items[i].string;\n+\t\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\t\tint j;\n+\n+\t\tstrvec_pushv(&cmd.args, argv.v);\n+\t\tstrvec_push(&cmd.args, name);\n+\n+\t\tfor (j = 0; j < refspec_nr; j++)\n+\t\t\tstrvec_push(&cmd.args, refspecs[j]);\n+\n+\t\tif (verbosity >= 0)\n+\t\t\tprintf(_(\"Pushing to %s\\n\"), name);\n+\n+\t\tcmd.git_cmd = 1;\n+\t\tif (run_command(&cmd)) {\n+\t\t\terror(_(\"could not push to %s\"), name);\n+\t\t\tresult = 1;\n+\t\t}\n+\t}\n+\n+\tstrvec_clear(&argv);\n+\treturn result;\n+}\n+\n int cmd_push(int argc,\n \t     const char **argv,\n \t     const char *prefix,\n@@ -552,12 +670,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n+\tint base_flags;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP;\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -620,39 +739,45 @@ int cmd_push(int argc,\n \telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n \t\tflags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;\n \n-\tif (tags)\n-\t\trefspec_append(&rs, \"refs/tags/*\");\n-\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n-\t}\n-\n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n-\n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n-\n-\tif (flags & TRANSPORT_PUSH_ALL) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n-\t}\n-\tif (flags & TRANSPORT_PUSH_MIRROR) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group)) {\n+\t\t\t/*\n+\t\t\t * Not a configured remote name or group name.\n+\t\t\t * Try treating it as a direct URL or path, e.g.\n+\t\t\t *   git push /tmp/foo.git\n+\t\t\t *   git push https://github.com/user/repo.git\n+\t\t\t * pushremote_get() creates an anonymous remote\n+\t\t\t * from the URL so the loop below can handle it\n+\t\t\t * identically to a named remote.\n+\t\t\t */\n+\t\t\tstruct remote *r = pushremote_get(repo);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"bad repository '%s'\"), repo);\n+\t\t\tstring_list_append(&remote_group, r->name);\n+\t\t}\n+\t} else {\n+\t\tstruct remote *r = pushremote_get(NULL);\n+\t\tif (!r)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"To push to multiple remotes at once, configure a remote group using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git config remotes.<groupname> \\\"<remote1> <remote2>\\\"\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the group name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <groupname>\\n\"));\n+\t\tstring_list_append(&remote_group, r->name);\n \t}\n \n \tif (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))\n@@ -662,10 +787,70 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\tif (remote_group.nr == 1) {\n+\t\t/*\n+\t\t * Single remote (the common case): run do_push() directly\n+\t\t * in this process.  The loop runs exactly once.\n+\t\t *\n+\t\t * Mirror detection and the --mirror/--all + refspec conflict\n+\t\t * checks are done here.  rs is rebuilt so that per-remote push\n+\t\t * mappings (remote.NAME.push config) are resolved against the\n+\t\t * correct remote.  inner_flags is a snapshot of flags so that a\n+\t\t * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any\n+\t\t * subsequent call.\n+\t\t */\n+\t\tbase_flags = flags;\n+\t\t{\n+\t\t\tint inner_flags = base_flags;\n+\t\t\tstruct remote *r = pushremote_get(remote_group.items[0].string);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t\t    remote_group.items[0].string);\n+\n+\t\t\tif (r->mirror)\n+\t\t\t\tinner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_ALL) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n+\t\t\t}\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_MIRROR) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\t\t\t}\n+\n+\t\t\trefspec_clear(&rs);\n+\t\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\t\tif (tags)\n+\t\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\t\tif (argc > 0)\n+\t\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\t\trc = do_push(inner_flags, push_options, r);\n+\t\t}\n+\t} else {\n+\t\t/*\n+\t\t * Multiple remotes: spawn one \"git push <remote> [<refspecs>]\"\n+\t\t * subprocess per remote, sequentially.\n+\t\t *\n+\t\t * Options that only make sense for a single transport connection\n+\t\t * are rejected here.\n+\t\t */\n+\t\tif (flags & TRANSPORT_PUSH_ATOMIC)\n+\t\t\tdie(_(\"--atomic can only be used when pushing to one remote\"));\n+\n+\t\trc = push_multiple(&remote_group, push_options, flags,\n+\t\t\t\t   tags,\n+\t\t\t\t   argc > 1 ? argv + 1 : NULL,\n+\t\t\t\t   argc > 1 ? argc - 1 : 0);\n+\t}\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..bd090627e9 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -704,6 +704,7 @@ integration_tests = [\n   't5563-simple-http-auth.sh',\n   't5564-http-proxy.sh',\n   't5565-push-multiple.sh',\n+  't5566-push-group.sh',\n   't5570-git-daemon.sh',\n   't5571-pre-push-hook.sh',\n   't5572-pull-submodule.sh',\ndiff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\nnew file mode 100755\nindex 0000000000..a7d59352b1\n--- /dev/null\n+++ b/t/t5566-push-group.sh\n@@ -0,0 +1,160 @@\n+#!/bin/sh\n+\n+test_description='push to remote group'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit init --bare dest-$i.git &&\n+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"initial\" &&\n+\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n+\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n+\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n+\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n+\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n+\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n+\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n+'\n+\n+test_expect_success 'push to remote group updates all members correctly' '\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push second commit to group updates all members' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"second\" &&\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push to single remote in group does not affect others' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"third\" &&\n+\tgit push remote-1 HEAD:refs/heads/main &&\n+\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n+\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n+\t! test_cmp hash-after-1 hash-after-2\n+'\n+\n+test_expect_success 'mirror remote in group with refspec fails' '\n+\tgit config set remote.remote-1.mirror true &&\n+\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"mirror\" err &&\n+\tgit config unset remote.remote-1.mirror\n+'\n+\n+test_expect_success 'push.default=current works with group push' '\n+\tgit config set push.default current &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fifth\" &&\n+\tgit push all-remotes &&\n+\tgit config unset push.default\n+'\n+\n+test_expect_success '--atomic is rejected for group push' '\n+\ttest_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"atomic\" err\n+'\n+\n+test_expect_success 'push continues past rejection to remaining remotes' '\n+\tfor i in c1 c2 c3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.c1.url \"file://$(pwd)/dest-c1.git\" &&\n+\tgit config set remote.c2.url \"file://$(pwd)/dest-c2.git\" &&\n+\tgit config set remote.c3.url \"file://$(pwd)/dest-c3.git\" &&\n+\tgit config set remotes.continue-group \"c1 c2 c3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for continue test\" &&\n+\n+\t# initial sync\n+\tgit push continue-group HEAD:refs/heads/main &&\n+\n+\t# advance c2 independently\n+\tgit clone dest-c2.git tmp-c2 &&\n+\t(\n+\t\tcd tmp-c2 &&\n+\t\tgit checkout -b main origin/main &&\n+\t\ttest_commit c2_independent &&\n+\t\tgit push origin HEAD:refs/heads/main\n+\t) &&\n+\trm -rf tmp-c2 &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"local diverging commit\" &&\n+\n+\t# push: c2 rejects, others succeed\n+\ttest_must_fail git push continue-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\tgit -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&\n+\tgit -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&\n+\ttest_cmp expect actual-c1 &&\n+\ttest_cmp expect actual-c3 &&\n+\n+\t# c2 should not have the new commit\n+\tgit -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&\n+\t! test_cmp expect actual-c2\n+'\n+\n+test_expect_success 'fatal connection error does not stop remaining remotes' '\n+\tfor i in f1 f2 f3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.f1.url \"file://$(pwd)/dest-f1.git\" &&\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\" &&\n+\tgit config set remote.f3.url \"file://$(pwd)/dest-f3.git\" &&\n+\tgit config set remotes.fatal-group \"f1 f2 f3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for fatal test\" &&\n+\n+\t# initial sync\n+\tgit push fatal-group HEAD:refs/heads/main &&\n+\n+\t# break f2\n+\tgit config set remote.f2.url \"file:///tmp/does-not-exist-$$\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"after fatal setup\" &&\n+\n+\t# overall exit code is non-zero because f2 failed\n+\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\n+\t# f1 and f3 should both have the new commit — subprocesses are independent\n+\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n+\ttest_cmp expect actual-f1 &&\n+\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n+\ttest_cmp expect actual-f3 &&\n+\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n+'\n+\n+test_done\n-- \n2.53.0\n\n"},{"id":"543181","messageId":"d6566004-f803-4824-b050-f086b6d6d76c@app.fastmail.com","threadId":"65145","inReplyTo":"20260503153402.1333220-4-usmanakinyemi202@gmail.com","subject":"Re: [RFC PATCH v5 3/3] push: support pushing to a remote group","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-05-12T15:05:56Z","receivedAt":"2026-05-12T15:06:19Z","isPatch":true,"body":"On Sun, May 3, 2026, at 17:34, Usman Akinyemi wrote:\n> `git fetch` accepts a remote group name (configured via `remotes.<name>`\n> in config) and fetches from each member remote. `git push` has no\n> equivalent — it only accepts a single remote name.\n>\n>[snip]\n>\n> +This means the user is responsible for ensuring that the sequence of\n> +individual pushes makes sense. If `git push r1`` would fail for a given\n\nDouble backticks (s/`git push r1``/`git push r1`/).\n\n> +set of options and arguments, then `git push all-remotes` will fail in\n> +the same way when it reaches r1. The group push does not do anything\n\ns/r1/`r1`/ ? Goes for the others here as well\n\n> +special to make a failing individual push succeed.\n> +\n>  OUTPUT\n>  ------\n>[snip]\n"},{"id":"543550","messageId":"20260518182721.155070-1-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260503153402.1333220-4-usmanakinyemi202@gmail.com","subject":"[RFC PATCH v5 0/3] push: add support for pushing to remote groups","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-18T18:27:18Z","receivedAt":"2026-05-18T18:27:26Z","isPatch":true,"body":"This RFC series adds support for `git push` to accept a remote group\nname (as configured via `remotes.<name>` in config) in addition to a\nsingle remote name, mirroring the behaviour that `git fetch` has\nsupported for some time.\n\nA user with multiple remotes configured as a group can now do:\n\n    git push all-remotes\n\ninstead of pushing to each remote individually, in the same way that:\n\n    git fetch all-remotes\n\nalready works.\n\nThe series is split into three patches:\n\n  - Patch 1 fix sign-compare warnings in push_cas_option\n  - Patch 2 moves `get_remote_group`, `add_remote_or_group`, and the\n    `remote_group_data` struct out of builtin/fetch.c and into\n    remote.c/remote.h, making them part of the public remote API.\n\n  - Patch 2 extends builtin/push.c to use the newly public\n    `add_remote_or_group()` to resolve the repository argument as\n    either a single remote or a group, and pushes to each member of\n    the group in turn.\n\nChanges in v6:\n- fix docs formating \n\nRange-diff v5 -> v6:\n\n1:  e01126890c = 1:  e01126890c remote: fix sign-compare warnings in push_cas_option\n2:  adbce652e6 = 2:  adbce652e6 remote: move remote group resolution to remote.c\n3:  a8d5f4b7bd ! 3:  62a4499be6 push: support pushing to a remote group\n    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`\n     +\t...\n     +\tgit push <options> rN <args>\n     +\n    -+where r1, r2, ..., rN are the members of `all-remotes`.  No special\n    ++where `r1`, `r2`, ..., `rN` are the members of `all-remotes`.  No special\n     +behaviour is added or removed — the group is purely a shorthand for\n     +running the same push command against each member remote individually.\n     +\n    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`\n     +any member push fails.\n     +\n     +This means the user is responsible for ensuring that the sequence of\n    -+individual pushes makes sense. If `git push r1`` would fail for a given\n    ++individual pushes makes sense. If `git push r1` would fail for a given\n     +set of options and arguments, then `git push all-remotes` will fail in\n    -+the same way when it reaches r1. The group push does not do anything\n    ++the same way when it reaches `r1`. The group push does not do anything\n     +special to make a failing individual push succeed.\n     +\n      OUTPUT\n\nUsman Akinyemi (3):\n  remote: fix sign-compare warnings in push_cas_option\n  remote: move remote group resolution to remote.c\n  push: support pushing to a remote group\n\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/fetch.c             |  42 ------\n builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----\n remote.c                    |  37 ++++++\n remote.h                    |  16 ++-\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 7 files changed, 502 insertions(+), 85 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\n-- \n2.54.0\n\n"},{"id":"543551","messageId":"20260518182721.155070-2-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260518182721.155070-1-usmanakinyemi202@gmail.com","subject":"[PATCH v6 1/3] remote: fix sign-compare warnings in push_cas_option","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-18T18:27:19Z","receivedAt":"2026-05-18T18:27:29Z","isPatch":true,"body":"Replace `int` with `size_t` for `nr` and `alloc` in\n`struct push_cas_option` to avoid -Werror=sign-compare\nwarnings when comparing against size-based values.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n remote.h | 4 ++--\n 1 file changed, 2 insertions(+), 2 deletions(-)\n\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..741d14a9fc 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -418,8 +418,8 @@ struct push_cas_option {\n \t\tunsigned use_tracking:1;\n \t\tchar *refname;\n \t} *entry;\n-\tint nr;\n-\tint alloc;\n+\tsize_t nr;\n+\tsize_t alloc;\n };\n \n int parseopt_push_cas_option(const struct option *, const char *arg, int unset);\n-- \n2.54.0\n\n"},{"id":"543552","messageId":"20260518182721.155070-3-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260518182721.155070-1-usmanakinyemi202@gmail.com","subject":"[PATCH v6 2/3] remote: move remote group resolution to remote.c","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-18T18:27:20Z","receivedAt":"2026-05-18T18:27:32Z","isPatch":true,"body":"`get_remote_group`, `add_remote_or_group`, and the `remote_group_data`\nstruct are currently defined as static helpers inside builtin/fetch.c.\nThey implement generic remote group resolution that is not specific to\nfetch — they parse `remotes.<name>` config entries and resolve a name\nto either a list of group members or a single configured remote.\n\nMove them to remote.c and declare them in remote.h so that other\nbuiltins can use the same logic without duplication.\n\nUseful for the next patch.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n builtin/fetch.c | 42 ------------------------------------------\n remote.c        | 37 +++++++++++++++++++++++++++++++++++++\n remote.h        | 12 ++++++++++++\n 3 files changed, 49 insertions(+), 42 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..cfb26eb284 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)\n \treturn 0;\n }\n \n-struct remote_group_data {\n-\tconst char *name;\n-\tstruct string_list *list;\n-};\n-\n-static int get_remote_group(const char *key, const char *value,\n-\t\t\t    const struct config_context *ctx UNUSED,\n-\t\t\t    void *priv)\n-{\n-\tstruct remote_group_data *g = priv;\n-\n-\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n-\t\t/* split list by white space */\n-\t\twhile (*value) {\n-\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n-\n-\t\t\tif (wordlen >= 1)\n-\t\t\t\tstring_list_append_nodup(g->list,\n-\t\t\t\t\t\t   xstrndup(value, wordlen));\n-\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n-\t\t}\n-\t}\n-\n-\treturn 0;\n-}\n-\n-static int add_remote_or_group(const char *name, struct string_list *list)\n-{\n-\tint prev_nr = list->nr;\n-\tstruct remote_group_data g;\n-\tg.name = name; g.list = list;\n-\n-\trepo_config(the_repository, get_remote_group, &g);\n-\tif (list->nr == prev_nr) {\n-\t\tstruct remote *remote = remote_get(name);\n-\t\tif (!remote_is_configured(remote, 0))\n-\t\t\treturn 0;\n-\t\tstring_list_append(list, remote->name);\n-\t}\n-\treturn 1;\n-}\n-\n static void add_options_to_argv(struct strvec *argv,\n \t\t\t\tconst struct fetch_config *config)\n {\ndiff --git a/remote.c b/remote.c\nindex a664cd166a..7133d29332 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,\n \treturn 0;\n }\n \n+int get_remote_group(const char *key, const char *value,\n+\t\t\t    const struct config_context *ctx UNUSED,\n+\t\t\t    void *priv)\n+{\n+\tstruct remote_group_data *g = priv;\n+\n+\tif (skip_prefix(key, \"remotes.\", &key) && !strcmp(key, g->name)) {\n+\t\t/* split list by white space */\n+\t\twhile (*value) {\n+\t\t\tsize_t wordlen = strcspn(value, \" \\t\\n\");\n+\n+\t\t\tif (wordlen >= 1)\n+\t\t\t\tstring_list_append_nodup(g->list,\n+\t\t\t\t\t\t   xstrndup(value, wordlen));\n+\t\t\tvalue += wordlen + (value[wordlen] != '\\0');\n+\t\t}\n+\t}\n+\n+\treturn 0;\n+}\n+\n+int add_remote_or_group(const char *name, struct string_list *list)\n+{\n+\tint prev_nr = list->nr;\n+\tstruct remote_group_data g;\n+\tg.name = name; g.list = list;\n+\n+\trepo_config(the_repository, get_remote_group, &g);\n+\tif (list->nr == prev_nr) {\n+\t\tstruct remote *remote = remote_get(name);\n+\t\tif (!remote_is_configured(remote, 0))\n+\t\t\treturn 0;\n+\t\tstring_list_append(list, remote->name);\n+\t}\n+\treturn 1;\n+}\n+\n int resolve_remote_symref(struct ref *ref, struct ref *list)\n {\n \tif (!ref->symref)\ndiff --git a/remote.h b/remote.h\nindex 741d14a9fc..7915be3111 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);\n \n int branch_merge_matches(struct branch *, int n, const char *);\n \n+/* list of the remote in a group as configured */\n+struct remote_group_data {\n+\tconst char *name;\n+\tstruct string_list *list;\n+};\n+\n+int get_remote_group(const char *key, const char *value,\n+                    const struct config_context *ctx,\n+                    void *priv);\n+\n+int add_remote_or_group(const char *name, struct string_list *list);\n+\n /**\n  * Return the fully-qualified refname of the tracking branch for `branch`.\n  * I.e., what \"branch@{upstream}\" would give you. Returns NULL if no\n-- \n2.54.0\n\n"},{"id":"543553","messageId":"20260518182721.155070-4-usmanakinyemi202@gmail.com","threadId":"65145","inReplyTo":"20260518182721.155070-1-usmanakinyemi202@gmail.com","subject":"[PATCH v6 3/3] push: support pushing to a remote group","fromName":"Usman Akinyemi","fromEmail":"usmanakinyemi202@gmail.com","sentAt":"2026-05-18T18:27:21Z","receivedAt":"2026-05-18T18:27:36Z","isPatch":true,"body":"`git fetch` accepts a remote group name (configured via `remotes.<name>`\nin config) and fetches from each member remote. `git push` has no\nequivalent — it only accepts a single remote name.\n\nTeach `git push` to resolve its repository argument through\n`add_remote_or_group()`, which was made public in the previous patch,\nso that a user can push to all remotes in a group with:\n\n    git push <group>\n\nWhen the argument resolves to a single remote, the behaviour is\nidentical to before. When it resolves to a group, each member remote\nis pushed in sequence.\n\nThe group push path rebuilds the refspec list (`rs`) from scratch for\neach member remote so that per-remote push mappings configured via\n`remote.<name>.push` are resolved correctly against each specific\nremote. Without this, refspec entries would accumulate across iterations\nand each subsequent remote would receive a growing list of duplicated\nentries.\n\nMirror detection (`remote->mirror`) is also evaluated per remote using\na copy of the flags, so that a mirror remote in the group cannot set\nTRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.\n\nSuggested-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>\n---\n Documentation/git-push.adoc |  80 ++++++++++--\n builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----\n t/meson.build               |   1 +\n t/t5566-push-group.sh       | 160 +++++++++++++++++++++++\n 4 files changed, 451 insertions(+), 41 deletions(-)\n create mode 100755 t/t5566-push-group.sh\n\ndiff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc\nindex e5ba3a6742..2ac16a9d78 100644\n--- a/Documentation/git-push.adoc\n+++ b/Documentation/git-push.adoc\n@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n\n \n DESCRIPTION\n -----------\n-\n-Updates one or more branches, tags, or other references in a remote\n-repository from your local repository, and sends all necessary data\n-that isn't already on the remote.\n+Updates one or more branches, tags, or other references in one or more\n+remote repositories from your local repository, and sends all necessary\n+data that isn't already on the remote.\n \n The simplest way to push is `git push <remote> <branch>`.\n `git push origin main` will push the local `main` branch to the `main`\n branch on the remote named `origin`.\n \n-The `<repository>` argument defaults to the upstream for the current branch,\n-or `origin` if there's no configured upstream.\n+You can also push to multiple remotes at once by using a remote group.\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"origin gitlab backup\"\n+\n+Then `git push all-remotes` will push to `origin`, `gitlab`, and\n+`backup` in turn, as if you had run `git push` against each one\n+individually.  Each remote is pushed independently using its own\n+push mapping configuration. There is a `remotes.<group>` entry in\n+the configuration file. (See linkgit:git-config[1]).\n+\n+The `<repository>` argument defaults to the upstream for the current\n+branch, or `origin` if there's no configured upstream.\n \n To decide which branches, tags, or other refs to push, Git uses\n (in order of precedence):\n@@ -55,8 +66,10 @@ OPTIONS\n _<repository>_::\n \tThe \"remote\" repository that is the destination of a push\n \toperation.  This parameter can be either a URL\n-\t(see the section <<URLS,GIT URLS>> below) or the name\n-\tof a remote (see the section <<REMOTES,REMOTES>> below).\n+\t(see the section <<URLS,GIT URLS>> below), the name\n+\tof a remote (see the section <<REMOTES,REMOTES>> below),\n+\tor the name of a remote group\n+\t(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).\n \n `<refspec>...`::\n \tSpecify what destination ref to update with what source object.\n@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.\n \n include::urls-remotes.adoc[]\n \n+[[REMOTE-GROUPS]]\n+REMOTE GROUPS\n+-------------\n+\n+A remote group is a named list of remotes configured via `remotes.<name>`\n+in your git config:\n+\n+\t$ git config remotes.all-remotes \"r1 r2 r3\"\n+\n+When a group name is given as the `<repository>` argument, the push is\n+performed to each member remote in turn.  The defining principle is:\n+\n+\tgit push <options> all-remotes <args>\n+\n+is exactly equivalent to:\n+\n+\tgit push <options> r1 <args>\n+\tgit push <options> r2 <args>\n+\t...\n+\tgit push <options> rN <args>\n+\n+where `r1`, `r2`, ..., `rN` are the members of `all-remotes`.  No special\n+behaviour is added or removed — the group is purely a shorthand for\n+running the same push command against each member remote individually.\n+\n+When pushing to a group of more than one remote, Git spawns a separate\n+`git push` subprocess for each member remote in sequence.  Each subprocess\n+receives the same flags and refspecs as the original invocation.  This\n+means that per-remote push mappings configured via `remote.<name>.push`\n+and mirror mode (`remote.<name>.mirror`) are evaluated independently for\n+each remote, and a mirror remote in the group cannot affect the push\n+behaviour of other non-mirror remotes in the same group.\n+\n+The `--atomic` option is not supported for group pushes, because atomicity\n+can only be guaranteed within a single transport connection to a single\n+remote.  Git will refuse the invocation with an error if `--atomic` is\n+combined with a group name.\n+\n+If any member remote fails whether due to a push rejection (e.g. a\n+non-fast-forward update, a server-side hook refusing a ref) or a connection\n+error (e.g. the repository does not exist, authentication fails, or the\n+network is unreachable), Git reports the error and continues pushing to\n+the remaining remotes in the group.  The overall exit code is non-zero if\n+any member push fails.\n+\n+This means the user is responsible for ensuring that the sequence of\n+individual pushes makes sense. If `git push r1` would fail for a given\n+set of options and arguments, then `git push all-remotes` will fail in\n+the same way when it reaches `r1`. The group push does not do anything\n+special to make a failing individual push succeed.\n+\n OUTPUT\n ------\n \ndiff --git a/builtin/push.c b/builtin/push.c\nindex 7100ffba5d..6021b71d66 100644\n--- a/builtin/push.c\n+++ b/builtin/push.c\n@@ -10,6 +10,7 @@\n #include \"config.h\"\n #include \"environment.h\"\n #include \"gettext.h\"\n+#include \"hex.h\"\n #include \"refspec.h\"\n #include \"run-command.h\"\n #include \"remote.h\"\n@@ -544,6 +545,123 @@ static int git_push_config(const char *k, const char *v,\n \treturn git_default_config(k, v, ctx, NULL);\n }\n \n+static int push_multiple(struct string_list *list,\n+\t\t\t const struct string_list *push_options,\n+\t\t\t int flags,\n+\t\t\t int tags,\n+\t\t\t const char **refspecs,\n+\t\t\t int refspec_nr)\n+{\n+\tint result = 0;\n+\tsize_t i;\n+\tstruct strvec argv = STRVEC_INIT;\n+\n+\tstrvec_push(&argv, \"push\");\n+\n+\tif (flags & TRANSPORT_PUSH_FORCE)\n+\t\tstrvec_push(&argv, \"--force\");\n+\tif (flags & TRANSPORT_PUSH_DRY_RUN)\n+\t\tstrvec_push(&argv, \"--dry-run\");\n+\tif (flags & TRANSPORT_PUSH_PORCELAIN)\n+\t\tstrvec_push(&argv, \"--porcelain\");\n+\tif (flags & TRANSPORT_PUSH_PRUNE)\n+\t\tstrvec_push(&argv, \"--prune\");\n+\tif (flags & TRANSPORT_PUSH_NO_HOOK)\n+\t\tstrvec_push(&argv, \"--no-verify\");\n+\tif (flags & TRANSPORT_PUSH_FOLLOW_TAGS)\n+\t\tstrvec_push(&argv, \"--follow-tags\");\n+\tif (flags & TRANSPORT_PUSH_SET_UPSTREAM)\n+\t\tstrvec_push(&argv, \"--set-upstream\");\n+\tif (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)\n+\t\tstrvec_push(&argv, \"--force-if-includes\");\n+\tif (flags & TRANSPORT_PUSH_ALL)\n+\t\tstrvec_push(&argv, \"--all\");\n+\tif (flags & TRANSPORT_PUSH_MIRROR)\n+\t\tstrvec_push(&argv, \"--mirror\");\n+\n+\tif (flags & TRANSPORT_PUSH_CERT_ALWAYS)\n+\t\tstrvec_push(&argv, \"--signed=yes\");\n+\telse if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)\n+\t\tstrvec_push(&argv, \"--signed=if-asked\");\n+\tif (!thin)\n+\t\tstrvec_push(&argv, \"--no-thin\");\n+\n+\tif (deleterefs)\n+\t\tstrvec_push(&argv, \"--delete\");\n+\n+\tif (receivepack)\n+\t\tstrvec_pushf(&argv, \"--receive-pack=%s\", receivepack);\n+\tif (verbosity >= 2)\n+\t\tstrvec_push(&argv, \"-v\");\n+\tif (verbosity >= 1)\n+\t\tstrvec_push(&argv, \"-v\");\n+\telse if (verbosity < 0)\n+\t\tstrvec_push(&argv, \"-q\");\n+\tif (progress > 0)\n+\t\tstrvec_push(&argv, \"--progress\");\n+\telse if (progress == 0)\n+\t\tstrvec_push(&argv, \"--no-progress\");\n+\n+\tif (family == TRANSPORT_FAMILY_IPV4)\n+\t\tstrvec_push(&argv, \"--ipv4\");\n+\telse if (family == TRANSPORT_FAMILY_IPV6)\n+\t\tstrvec_push(&argv, \"--ipv6\");\n+\n+\tif (recurse_submodules == RECURSE_SUBMODULES_CHECK)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=check\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=on-demand\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=only\");\n+\telse if (recurse_submodules == RECURSE_SUBMODULES_OFF)\n+\t\tstrvec_push(&argv, \"--recurse-submodules=no\");\n+\n+\n+\tif (tags)\n+\t\tstrvec_push(&argv, \"--tags\");\n+\n+\tfor (i = 0; i < push_options->nr; i++)\n+\t\tstrvec_pushf(&argv, \"--push-option=%s\",\n+\t\t\t     push_options->items[i].string);\n+\n+\tfor (i = 0; i < cas.nr; i++) {\n+\t\tif (cas.entry[i].use_tracking) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s\",\n+\t\t\t\t     cas.entry[i].refname);\n+\t\t} else if (!is_null_oid(&cas.entry[i].expect)) {\n+\t\t\tstrvec_pushf(&argv, \"--force-with-lease=%s:%s\",\n+\t\t\t\t     cas.entry[i].refname,\n+\t\t\t\t     oid_to_hex(&cas.entry[i].expect));\n+\t\t} else {\n+\t\t\tstrvec_push(&argv, \"--force-with-lease\");\n+\t\t}\n+\t}\n+\n+\tfor (i = 0; i < list->nr; i++) {\n+\t\tconst char *name = list->items[i].string;\n+\t\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\t\tint j;\n+\n+\t\tstrvec_pushv(&cmd.args, argv.v);\n+\t\tstrvec_push(&cmd.args, name);\n+\n+\t\tfor (j = 0; j < refspec_nr; j++)\n+\t\t\tstrvec_push(&cmd.args, refspecs[j]);\n+\n+\t\tif (verbosity >= 0)\n+\t\t\tprintf(_(\"Pushing to %s\\n\"), name);\n+\n+\t\tcmd.git_cmd = 1;\n+\t\tif (run_command(&cmd)) {\n+\t\t\terror(_(\"could not push to %s\"), name);\n+\t\t\tresult = 1;\n+\t\t}\n+\t}\n+\n+\tstrvec_clear(&argv);\n+\treturn result;\n+}\n+\n int cmd_push(int argc,\n \t     const char **argv,\n \t     const char *prefix,\n@@ -552,12 +670,13 @@ int cmd_push(int argc,\n \tint flags = 0;\n \tint tags = 0;\n \tint push_cert = -1;\n-\tint rc;\n+\tint rc = 0;\n+\tint base_flags;\n \tconst char *repo = NULL;\t/* default repository */\n \tstruct string_list push_options_cmdline = STRING_LIST_INIT_DUP;\n+\tstruct string_list remote_group = STRING_LIST_INIT_DUP;\n \tstruct string_list *push_options;\n \tconst struct string_list_item *item;\n-\tstruct remote *remote;\n \n \tstruct option options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -620,39 +739,45 @@ int cmd_push(int argc,\n \telse if (recurse_submodules == RECURSE_SUBMODULES_ONLY)\n \t\tflags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;\n \n-\tif (tags)\n-\t\trefspec_append(&rs, \"refs/tags/*\");\n-\n \tif (argc > 0)\n \t\trepo = argv[0];\n \n-\tremote = pushremote_get(repo);\n-\tif (!remote) {\n-\t\tif (repo)\n-\t\t\tdie(_(\"bad repository '%s'\"), repo);\n-\t\tdie(_(\"No configured push destination.\\n\"\n-\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git remote add <name> <url>\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"and then push using the remote name\\n\"\n-\t\t    \"\\n\"\n-\t\t    \"    git push <name>\\n\"));\n-\t}\n-\n-\tif (argc > 0)\n-\t\tset_refspecs(argv + 1, argc - 1, remote);\n-\n-\tif (remote->mirror)\n-\t\tflags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n-\n-\tif (flags & TRANSPORT_PUSH_ALL) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n-\t}\n-\tif (flags & TRANSPORT_PUSH_MIRROR) {\n-\t\tif (argc >= 2)\n-\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\tif (repo) {\n+\t\tif (!add_remote_or_group(repo, &remote_group)) {\n+\t\t\t/*\n+\t\t\t * Not a configured remote name or group name.\n+\t\t\t * Try treating it as a direct URL or path, e.g.\n+\t\t\t *   git push /tmp/foo.git\n+\t\t\t *   git push https://github.com/user/repo.git\n+\t\t\t * pushremote_get() creates an anonymous remote\n+\t\t\t * from the URL so the loop below can handle it\n+\t\t\t * identically to a named remote.\n+\t\t\t */\n+\t\t\tstruct remote *r = pushremote_get(repo);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"bad repository '%s'\"), repo);\n+\t\t\tstring_list_append(&remote_group, r->name);\n+\t\t}\n+\t} else {\n+\t\tstruct remote *r = pushremote_get(NULL);\n+\t\tif (!r)\n+\t\t\tdie(_(\"No configured push destination.\\n\"\n+\t\t\t    \"Either specify the URL from the command-line or configure a remote repository using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git remote add <name> <url>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the remote name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <name>\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"To push to multiple remotes at once, configure a remote group using\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git config remotes.<groupname> \\\"<remote1> <remote2>\\\"\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"and then push using the group name\\n\"\n+\t\t\t    \"\\n\"\n+\t\t\t    \"    git push <groupname>\\n\"));\n+\t\tstring_list_append(&remote_group, r->name);\n \t}\n \n \tif (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))\n@@ -662,10 +787,70 @@ int cmd_push(int argc,\n \t\tif (strchr(item->string, '\\n'))\n \t\t\tdie(_(\"push options must not have new line characters\"));\n \n-\trc = do_push(flags, push_options, remote);\n+\tif (remote_group.nr == 1) {\n+\t\t/*\n+\t\t * Single remote (the common case): run do_push() directly\n+\t\t * in this process.  The loop runs exactly once.\n+\t\t *\n+\t\t * Mirror detection and the --mirror/--all + refspec conflict\n+\t\t * checks are done here.  rs is rebuilt so that per-remote push\n+\t\t * mappings (remote.NAME.push config) are resolved against the\n+\t\t * correct remote.  inner_flags is a snapshot of flags so that a\n+\t\t * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any\n+\t\t * subsequent call.\n+\t\t */\n+\t\tbase_flags = flags;\n+\t\t{\n+\t\t\tint inner_flags = base_flags;\n+\t\t\tstruct remote *r = pushremote_get(remote_group.items[0].string);\n+\t\t\tif (!r)\n+\t\t\t\tdie(_(\"no such remote or remote group: %s\"),\n+\t\t\t\t    remote_group.items[0].string);\n+\n+\t\t\tif (r->mirror)\n+\t\t\t\tinner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);\n+\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_ALL) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--all can't be combined with refspecs\"));\n+\t\t\t}\n+\t\t\tif (inner_flags & TRANSPORT_PUSH_MIRROR) {\n+\t\t\t\tif (argc >= 2)\n+\t\t\t\t\tdie(_(\"--mirror can't be combined with refspecs\"));\n+\t\t\t}\n+\n+\t\t\trefspec_clear(&rs);\n+\t\t\trs = (struct refspec) REFSPEC_INIT_PUSH;\n+\n+\t\t\tif (tags)\n+\t\t\t\trefspec_append(&rs, \"refs/tags/*\");\n+\t\t\tif (argc > 0)\n+\t\t\t\tset_refspecs(argv + 1, argc - 1, r);\n+\n+\t\t\trc = do_push(inner_flags, push_options, r);\n+\t\t}\n+\t} else {\n+\t\t/*\n+\t\t * Multiple remotes: spawn one \"git push <remote> [<refspecs>]\"\n+\t\t * subprocess per remote, sequentially.\n+\t\t *\n+\t\t * Options that only make sense for a single transport connection\n+\t\t * are rejected here.\n+\t\t */\n+\t\tif (flags & TRANSPORT_PUSH_ATOMIC)\n+\t\t\tdie(_(\"--atomic can only be used when pushing to one remote\"));\n+\n+\t\trc = push_multiple(&remote_group, push_options, flags,\n+\t\t\t\t   tags,\n+\t\t\t\t   argc > 1 ? argv + 1 : NULL,\n+\t\t\t\t   argc > 1 ? argc - 1 : 0);\n+\t}\n+\n \tstring_list_clear(&push_options_cmdline, 0);\n \tstring_list_clear(&push_options_config, 0);\n+\tstring_list_clear(&remote_group, 0);\n \tclear_cas_option(&cas);\n+\n \tif (rc == -1)\n \t\tusage_with_options(push_usage, options);\n \telse\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..bd090627e9 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -704,6 +704,7 @@ integration_tests = [\n   't5563-simple-http-auth.sh',\n   't5564-http-proxy.sh',\n   't5565-push-multiple.sh',\n+  't5566-push-group.sh',\n   't5570-git-daemon.sh',\n   't5571-pre-push-hook.sh',\n   't5572-pull-submodule.sh',\ndiff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh\nnew file mode 100755\nindex 0000000000..a7d59352b1\n--- /dev/null\n+++ b/t/t5566-push-group.sh\n@@ -0,0 +1,160 @@\n+#!/bin/sh\n+\n+test_description='push to remote group'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup' '\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit init --bare dest-$i.git &&\n+\t\tgit -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||\n+\t\treturn 1\n+\tdone &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"initial\" &&\n+\tgit config set remote.remote-1.url \"file://$(pwd)/dest-1.git\" &&\n+\tgit config set remote.remote-1.fetch \"+refs/heads/*:refs/remotes/remote-1/*\" &&\n+\tgit config set remote.remote-2.url \"file://$(pwd)/dest-2.git\" &&\n+\tgit config set remote.remote-2.fetch \"+refs/heads/*:refs/remotes/remote-2/*\" &&\n+\tgit config set remote.remote-3.url \"file://$(pwd)/dest-3.git\" &&\n+\tgit config set remote.remote-3.fetch \"+refs/heads/*:refs/remotes/remote-3/*\" &&\n+\tgit config set remotes.all-remotes \"remote-1 remote-2 remote-3\"\n+'\n+\n+test_expect_success 'push to remote group updates all members correctly' '\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push second commit to group updates all members' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"second\" &&\n+\tgit push all-remotes HEAD:refs/heads/main &&\n+\tgit rev-parse HEAD >expect &&\n+\tfor i in 1 2 3\n+\tdo\n+\t\tgit -C dest-$i.git rev-parse refs/heads/main >actual ||\n+\t\treturn 1\n+\t\ttest_cmp expect actual || return 1\n+\tdone\n+'\n+\n+test_expect_success 'push to single remote in group does not affect others' '\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"third\" &&\n+\tgit push remote-1 HEAD:refs/heads/main &&\n+\tgit -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&\n+\tgit -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&\n+\t! test_cmp hash-after-1 hash-after-2\n+'\n+\n+test_expect_success 'mirror remote in group with refspec fails' '\n+\tgit config set remote.remote-1.mirror true &&\n+\ttest_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"mirror\" err &&\n+\tgit config unset remote.remote-1.mirror\n+'\n+\n+test_expect_success 'push.default=current works with group push' '\n+\tgit config set push.default current &&\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"fifth\" &&\n+\tgit push all-remotes &&\n+\tgit config unset push.default\n+'\n+\n+test_expect_success '--atomic is rejected for group push' '\n+\ttest_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&\n+\ttest_grep \"atomic\" err\n+'\n+\n+test_expect_success 'push continues past rejection to remaining remotes' '\n+\tfor i in c1 c2 c3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.c1.url \"file://$(pwd)/dest-c1.git\" &&\n+\tgit config set remote.c2.url \"file://$(pwd)/dest-c2.git\" &&\n+\tgit config set remote.c3.url \"file://$(pwd)/dest-c3.git\" &&\n+\tgit config set remotes.continue-group \"c1 c2 c3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for continue test\" &&\n+\n+\t# initial sync\n+\tgit push continue-group HEAD:refs/heads/main &&\n+\n+\t# advance c2 independently\n+\tgit clone dest-c2.git tmp-c2 &&\n+\t(\n+\t\tcd tmp-c2 &&\n+\t\tgit checkout -b main origin/main &&\n+\t\ttest_commit c2_independent &&\n+\t\tgit push origin HEAD:refs/heads/main\n+\t) &&\n+\trm -rf tmp-c2 &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"local diverging commit\" &&\n+\n+\t# push: c2 rejects, others succeed\n+\ttest_must_fail git push continue-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\tgit -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&\n+\tgit -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&\n+\ttest_cmp expect actual-c1 &&\n+\ttest_cmp expect actual-c3 &&\n+\n+\t# c2 should not have the new commit\n+\tgit -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&\n+\t! test_cmp expect actual-c2\n+'\n+\n+test_expect_success 'fatal connection error does not stop remaining remotes' '\n+\tfor i in f1 f2 f3\n+\tdo\n+\t\tgit init --bare dest-$i.git || return 1\n+\tdone &&\n+\tgit config set remote.f1.url \"file://$(pwd)/dest-f1.git\" &&\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\" &&\n+\tgit config set remote.f3.url \"file://$(pwd)/dest-f3.git\" &&\n+\tgit config set remotes.fatal-group \"f1 f2 f3\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"base for fatal test\" &&\n+\n+\t# initial sync\n+\tgit push fatal-group HEAD:refs/heads/main &&\n+\n+\t# break f2\n+\tgit config set remote.f2.url \"file:///tmp/does-not-exist-$$\" &&\n+\n+\ttest_tick &&\n+\tgit commit --allow-empty -m \"after fatal setup\" &&\n+\n+\t# overall exit code is non-zero because f2 failed\n+\ttest_must_fail git push fatal-group HEAD:refs/heads/main &&\n+\n+\tgit rev-parse HEAD >expect &&\n+\n+\t# f1 and f3 should both have the new commit — subprocesses are independent\n+\tgit -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&\n+\ttest_cmp expect actual-f1 &&\n+\tgit -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&\n+\ttest_cmp expect actual-f3 &&\n+\n+\tgit config set remote.f2.url \"file://$(pwd)/dest-f2.git\"\n+'\n+\n+test_done\n-- \n2.54.0\n\n"}]}