Volume XXII, number 280Wednesday, October 7, 2026Latest message 57 minutes ago

The Git List

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

RFC patch, 2 partspush: add support for pushing to remote groups

39 messages between Mar 5, 2026 and May 18, 2026, from Usman Akinyemi, Junio C Hamano, Kristoffer Haugsbakk.

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

Usman AkinyemiMar 5, 2026, 22:32 UTC on lore

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into two patches:
  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
RFC notes and open questions:
  - The current implementation pushes to group members sequentially.
    A follow-up could add push.parallel (mirroring fetch.parallel) to
    allow parallel pushes to group members via run_processes_parallel(),
    the same mechanism fetch uses. Feedback on whether this is
    desirable for push is welcome.
  - push.default = simple interacts poorly with group pushes when the
    current branch has no upstream set, since setup_default_push_refspecs()
    will die on the first remote that is not the upstream. Users should
    use push.default = current or explicit refspecs for group pushes.
    It is worth discussing whether the group push path should automatically
    imply push.default = current, or whether a clear error message
    directing the user to configure this would be sufficient.
  - force-with-lease semantics across a group push are currently
    unmodified — the same CAS constraints are forwarded to every remote
    in the group. Whether this is the right behaviour or whether
    per-remote lease tracking is needed is an open question.
  - I will also add the tests and documentations in the next iterations
Usman Akinyemi (2):
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 builtin/fetch.c | 42 -----------------------
 builtin/push.c  | 89 ++++++++++++++++++++++++++++++++++++++-----------
 remote.c        | 37 ++++++++++++++++++++
 remote.h        | 12 +++++++
 4 files changed, 119 insertions(+), 61 deletions(-)
-- 
2.53.0
Usman AkinyemiMar 5, 2026, 22:32 UTC in reply to Usman Akinyemi on lore

[RFC PATCH 1/2] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index 573c295241..6e56c484bf 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index f6980dc656..75fdb4019d 100644
--- a/remote.c
+++ b/remote.c
@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index fc052945ee..fa38f951a2 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.53.0
Usman AkinyemiMar 5, 2026, 22:32 UTC in reply to Usman Akinyemi on lore

[RFC PATCH 2/2] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

A known interaction: push.default = simple will die when the current branch has no upstream configured, because setup_default_push_refspecs() requires an upstream for that mode. Users pushing to a group should set push.default = current or supply explicit refspecs. This is consistent with how fetch handles default refspec resolution per remote.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/push.c | 89 +++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 70 insertions(+), 19 deletions(-)
Show changes to builtin/push.c +70 −19
diff --git a/builtin/push.c b/builtin/push.c
index 5b6cebbb85..a98fb4c934 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -551,12 +551,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP; /* represent remote or remote group */
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
+	struct remote *remote = NULL;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -625,25 +626,35 @@ int cmd_push(int argc,
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group))
+			die(_("no such remote or remote group: %s"), repo);
+	} else {
+		remote = pushremote_get(NULL);
+		if (!remote)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"));
 	}
 
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
+	/*
+	 * set_refspecs and mirror detection must not use `remote`
+	 * when it may be NULL (group path). For the single-remote case,
+	 * handle them here. For the group case they are handled
+	 * per-remote inside the loop below.
+	 */
+	if (remote) {
+		if (argc > 0)
+			set_refspecs(argv + 1, argc - 1, remote);
 
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+		if (remote->mirror)
+			flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+	}
 
 	if (flags & TRANSPORT_PUSH_ALL) {
 		if (argc >= 2)
@@ -661,10 +672,50 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	if (remote) {
+		rc = do_push(flags, push_options, remote);
+	} else {
+		int base_flags = flags;
+		for (int i = 0; i < remote_group.nr; i++) {
+			int iter_flags = base_flags;
+			struct remote *r = pushremote_get(remote_group.items[i].string);
+			if (!r)
+				die(_("no such remote or remote group: %s"),
+				    remote_group.items[i].string);
+
+			/*
+			 * Rebuild rs from scratch for each remote so that
+			 * push mappings (remote.NAME.push config) are resolved
+			 * against this specific remote. Without this, mappings
+			 * from a previous iteration would accumulate in rs and
+			 * each remote would be pushed an ever-growing refspec list.
+			 */
+			refspec_clear(&rs);
+			rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+			if (tags)
+				refspec_append(&rs, "refs/tags/*");
+			if (argc > 0)
+				set_refspecs(argv + 1, argc - 1, r);
+
+			/*
+			 * Compute mirror flag from a fresh base each iteration
+			 * so that a mirror remote does not bleed TRANSPORT_PUSH_FORCE
+			 * into subsequent non-mirror remotes in the same group.
+			 */
+			if (r->mirror)
+				iter_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+			rc |= do_push(iter_flags, push_options, r);
+		}
+	}
+
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
-- 
2.53.0
Junio C HamanoMar 6, 2026, 18:12 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 17 quoted lines
> diff --git a/remote.h b/remote.h
> index fc052945ee..fa38f951a2 100644
> --- a/remote.h
> +++ b/remote.h
> @@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
>  
>  int branch_merge_matches(struct branch *, int n, const char *);
>  
> +/* list of the remote in a group as configured */
> +struct remote_group_data {
> +	const char *name;
> +	struct string_list *list;
> +};
> +
> +int get_remote_group(const char *key, const char *value,
> +			    const struct config_context *ctx UNUSED,
> +			    void *priv);

It is dubious to carry UNUESD over to an external declaration in a public header file, unless it is a "static inline" definition that comes with the implementation.

Other than that, move looks correct and it is generally a good idea.

When moving functions and types that have been private to the implementation of a subsystem to public namespace, we need to be careful to consider if the names of these things are specific enough. With "remote_group" in them, they are all good as-is in this case, and can go public without giving them "better" names.

Thanks.
Junio C HamanoMar 7, 2026, 02:12 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 27 quoted lines
> -	remote = pushremote_get(repo);
> -	if (!remote) {
> -		if (repo)
> -			die(_("bad repository '%s'"), repo);
> -		die(_("No configured push destination.\n"
> -		    "Either specify the URL from the command-line or configure a remote repository using\n"
> -		    "\n"
> -		    "    git remote add <name> <url>\n"
> -		    "\n"
> -		    "and then push using the remote name\n"
> -		    "\n"
> -		    "    git push <name>\n"));
> +	if (repo) {
> +		if (!add_remote_or_group(repo, &remote_group))
> +			die(_("no such remote or remote group: %s"), repo);
> +	} else {
> +		remote = pushremote_get(NULL);
> +		if (!remote)
> +			die(_("No configured push destination.\n"
> +			    "Either specify the URL from the command-line or configure a remote repository using\n"
> +			    "\n"
> +			    "    git remote add <name> <url>\n"
> +			    "\n"
> +			    "and then push using the remote name\n"
> +			    "\n"
> +			    "    git push <name>\n"));
>  	}

The basic idea to use "remote" (the default remote cannot be multiple) vs "remote_group" (the command line gave which remotes to talk with) sounds good.

But I started wondering what happens when the command line gave a single remote to talk with. Probably we want a code that does

	if (remote_group has only one remote)
		remote = take the sole remote from the remote_group;

here before we continue. Or the other way around and we handle the "default remote cannot be multiple" case as a special case, e.g.

	if (remote) {
		create remote_group with a single member "remote";
		remote = NULL;
	}
and then we do not have to do ...
Show 6 quoted lines
> +	/*
> +	 * set_refspecs and mirror detection must not use `remote`
> +	 * when it may be NULL (group path). For the single-remote case,
> +	 * handle them here. For the group case they are handled
> +	 * per-remote inside the loop below.
> +	 */
... "handle them here because single-remote is special" at all, no?

I would prefer to avoid "X must be done for each remote in the remote-group, but Y can be done only once", as future developers will get it wrong when they add their own Z and consider which side Z falls into. The code structure that removes special case would help by making sure that a singleton case is special only because the loop over remote_group runs once, and otherwise there is nothing special goes on.

Thanks.
Usman AkinyemiMar 9, 2026, 00:43 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c

Show 7 quoted lines
> > +int get_remote_group(const char *key, const char *value,
> > +                         const struct config_context *ctx UNUSED,
> > +                         void *priv);
>
> It is dubious to carry UNUESD over to an external declaration in a
> public header file, unless it is a "static inline" definition that
> comes with the implementation.
Noted, thanks.
Show 8 quoted lines
>
> Other than that, move looks correct and it is generally a good idea.
>
> When moving functions and types that have been private to the
> implementation of a subsystem to public namespace, we need to be
> careful to consider if the names of these things are specific
> enough.  With "remote_group" in them, they are all good as-is in
> this case, and can go public without giving them "better" names.
Noted, thanks.
>
> Thanks.
>
Usman AkinyemiMar 9, 2026, 00:56 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH 2/2] push: support pushing to a remote group

Show 10 quoted lines
>
> The basic idea to use "remote" (the default remote cannot be multiple)
> vs "remote_group" (the command line gave which remotes to talk with)
> sounds good.
>
> But I started wondering what happens when the command line gave a
> single remote to talk with.  Probably we want a code that does
>
>         if (remote_group has only one remote)
>                 remote = take the sole remote from the remote_group;
Make sense.
Show 27 quoted lines
>
> here before we continue.  Or the other way around and we handle the
> "default remote cannot be multiple" case as a special case, e.g.
>
>         if (remote) {
>                 create remote_group with a single member "remote";
>                 remote = NULL;
>         }
>
> and then we do not have to do ...
>
> > +     /*
> > +      * set_refspecs and mirror detection must not use `remote`
> > +      * when it may be NULL (group path). For the single-remote case,
> > +      * handle them here. For the group case they are handled
> > +      * per-remote inside the loop below.
> > +      */
>
> ... "handle them here because single-remote is special" at all, no?
>
> I would prefer to avoid "X must be done for each remote in the
> remote-group, but Y can be done only once", as future developers
> will get it wrong when they add their own Z and consider which side
> Z falls into.  The code structure that removes special case would
> help by making sure that a singleton case is special only because
> the loop over remote_group runs once, and otherwise there is nothing
> special goes on.
Yeah, that is a good design and makes sense. Thanks.

Also, in the cover letter, I asked some questions. I think you might have missed it.

Quoting here again:
"
  - push.default = simple interacts poorly with group pushes when the
    current branch has no upstream set, since setup_default_push_refspecs()
    will die on the first remote that is not the upstream. Users should
    use push.default = current or explicit refspecs for group pushes.
    It is worth discussing whether the group push path should automatically
    imply push.default = current, or whether a clear error message
    directing the user to configure this would be sufficient.
  - force-with-lease semantics across a group push are currently
    unmodified — the same CAS constraints are forwarded to every remote
    in the group. Whether this is the right behaviour or whether
    per-remote lease tracking is needed is an open question.
"
I will want feedback on this also.
Thanks
Junio C HamanoMar 9, 2026, 13:38 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 20 quoted lines
> Also, in the cover letter, I asked some questions. I think you might
> have missed it.
>
> Quoting here again:
> "
>   - push.default = simple interacts poorly with group pushes when the
>     current branch has no upstream set, since setup_default_push_refspecs()
>     will die on the first remote that is not the upstream. Users should
>     use push.default = current or explicit refspecs for group pushes.
>     It is worth discussing whether the group push path should automatically
>     imply push.default = current, or whether a clear error message
>     directing the user to configure this would be sufficient.
>
>   - force-with-lease semantics across a group push are currently
>     unmodified — the same CAS constraints are forwarded to every remote
>     in the group. Whether this is the right behaviour or whether
>     per-remote lease tracking is needed is an open question.
> "
>
> I will want feedback on this also.

Quite honestly, I do not have strong opinions on either of these points, primarily because the answer would become self evident if we follow a simple general principle to explain this feature to end users, which is:

When you have N remotes r1, r2, ..., rN, and a remote group G that expands (possibly recursively) to these N remotes, then for any and all values of $options and $args, this command invocation

    $ git push $options G $args
should mean exactly the same thing as
    $ git push $options r1 $args
    $ git push $options r2 $args
    ...
    $ git push $options rN $args
So the answer to the first one would be:
    "git push r1" (without any other parameters) may work while "git
    push r2" (the same, wihtout any other parameters) may fail,
    depending on how the push.default is set and on what branch you
    run these two pushes.  "git push G" should behave the same way.
    There is nothing extra fancy needs to be done.  If the user
    wants to push to these N remotes as a whole in an identical way
    by using remote group G, they are the one who is responsible to
    make this sequences of pushes "git push r1; git push r2; ..."
    make sense.

The answer to the second one would be derived the same way. If $options includes the "--force-with-lease=<commit>", then the command should behave as if copies of the command that pushes to these N remotes, "git push --force-with-lease=<commit> r$i", are invoked. If <commit> is not given (which is not a recommended even for pushes to a single remote, because with background fetching, guesses based on the remote-tracking branches are never be reliable), the command may guess what commit to expect on the remote the same way as if these N independent pushes are made without using group feature (which of course may make different guesses for each remote, if their remote-tracking branches are pointing at different commits).

Usman AkinyemiMar 18, 2026, 20:40 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v2 0/2] push: add support for pushing to remote groups

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into two patches:
  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
RFC notes and open questions:
  - The current implementation pushes to group members sequentially.
    A follow-up could add push.parallel (mirroring fetch.parallel) to
    allow parallel pushes to group members via run_processes_parallel(),
    the same mechanism fetch uses. Feedback on whether this is
    desirable for push is welcome.
  - push.default = simple interacts poorly with group pushes when the
    current branch has no upstream set, since setup_default_push_refspecs()
    will die on the first remote that is not the upstream. Users should
    use push.default = current or explicit refspecs for group pushes.
    It is worth discussing whether the group push path should automatically
    imply push.default = current, or whether a clear error message
    directing the user to configure this would be sufficient.
  - force-with-lease semantics across a group push are currently
    unmodified — the same CAS constraints are forwarded to every remote
    in the group. Whether this is the right behaviour or whether
    per-remote lease tracking is needed is an open question.
  - I will also add the tests and documentations in the next iterations
Changes in v2:
  - Remove UNUSED from the declaration in remote.h (patch 1).
  - Drop the persistent `remote` variable from cmd_push entirely
    (patch 2). Following Junio's suggestion, the default remote
    case now folds into remote_group so the single-remote and
    group cases are handled by a single unified loop. There is
    no longer any structural difference between pushing to one
    remote and pushing to a group — a singleton is just a group
    of one.
  - Move the --mirror+refspec and --all+refspec conflict checks
    inside the loop so they are evaluated per remote.
  - Add a URL/path fallback so that direct path arguments like
      git push /tmp/foo.git
    continue to work correctly after the remote resolution
    change.
  - Add a test script t5528-push-group.sh covering the new
    group push behaviour.
  - Update Documentation/git-push.adoc: DESCRIPTION, the
    <repository> argument description, and a new REMOTE GROUPS
    section documenting the defining principle that
      git push <options> all-remotes <args>
    is exactly equivalent to running git push <options> r$i <args>
    for each member remote independently.
Usman Akinyemi (2):
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 Documentation/git-push.adoc |  76 +++++++++++++++++++---
 builtin/fetch.c             |  42 ------------
 builtin/push.c              | 124 ++++++++++++++++++++++++++----------
 remote.c                    |  37 +++++++++++
 remote.h                    |  12 ++++
 t/meson.build               |   1 +
 t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++
 7 files changed, 303 insertions(+), 84 deletions(-)
 create mode 100755 t/t5566-push-group.sh
-- 
2.53.0
Usman AkinyemiMar 18, 2026, 20:40 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v2 1/2] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index 573c295241..6e56c484bf 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index f6980dc656..75fdb4019d 100644
--- a/remote.c
+++ b/remote.c
@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index fc052945ee..8ff2bd88fa 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+                    const struct config_context *ctx,
+                    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.53.0
Usman AkinyemiMar 18, 2026, 20:40 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v2 2/2] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

A known interaction: push.default = simple will die when the current branch has no upstream configured, because setup_default_push_refspecs() requires an upstream for that mode. Users pushing to a group should set push.default = current or supply explicit refspecs. This is consistent with how fetch handles default refspec resolution per remote.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 Documentation/git-push.adoc |  76 +++++++++++++++++++---
 builtin/push.c              | 124 ++++++++++++++++++++++++++----------
 t/meson.build               |   1 +
 t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++
 4 files changed, 254 insertions(+), 42 deletions(-)
 create mode 100755 t/t5566-push-group.sh
Show changes to 4 files +254 −42

Documentation/git-push.adoc, builtin/push.c, t/meson.build, t/t5566-push-group.sh

diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
index e5ba3a6742..1a3c309002 100644
--- a/Documentation/git-push.adoc
+++ b/Documentation/git-push.adoc
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
 
 DESCRIPTION
 -----------
-
-Updates one or more branches, tags, or other references in a remote
-repository from your local repository, and sends all necessary data
-that isn't already on the remote.
+Updates one or more branches, tags, or other references in one or more
+remote repositories from your local repository, and sends all necessary
+data that isn't already on the remote.
 
 The simplest way to push is `git push <remote> <branch>`.
 `git push origin main` will push the local `main` branch to the `main`
 branch on the remote named `origin`.
 
-The `<repository>` argument defaults to the upstream for the current branch,
-or `origin` if there's no configured upstream.
+You can also push to multiple remotes at once by using a remote group.
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "origin gitlab backup"
+
+Then `git push all-remotes` will push to `origin`, `gitlab`, and
+`backup` in turn, as if you had run `git push` against each one
+individually.  Each remote is pushed independently using its own
+push mapping configuration. There is a `remotes.<group>` entry in
+the configuration file. (See linkgit:git-config[1]).
+
+The `<repository>` argument defaults to the upstream for the current
+branch, or `origin` if there's no configured upstream.
 
 To decide which branches, tags, or other refs to push, Git uses
 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
 _<repository>_::
 	The "remote" repository that is the destination of a push
 	operation.  This parameter can be either a URL
-	(see the section <<URLS,GIT URLS>> below) or the name
-	of a remote (see the section <<REMOTES,REMOTES>> below).
+	(see the section <<URLS,GIT URLS>> below), the name
+	of a remote (see the section <<REMOTES,REMOTES>> below),
+	or the name of a remote group
+	(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
 
 `<refspec>...`::
 	Specify what destination ref to update with what source object.
@@ -430,6 +443,53 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
 
 include::urls-remotes.adoc[]
 
+[[REMOTE-GROUPS]]
+REMOTE GROUPS
+-------------
+
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "r1 r2 r3"
+
+When a group name is given as the `<repository>` argument, the push is
+performed to each member remote in turn.  The defining principle is:
+
+	git push <options> all-remotes <args>
+
+is exactly equivalent to:
+
+	git push <options> r1 <args>
+	git push <options> r2 <args>
+	...
+	git push <options> rN <args>
+
+where r1, r2, ..., rN are the members of `all-remotes`.  No special
+behaviour is added or removed — the group is purely a shorthand for
+running the same push command against each member remote individually.
+
+This means the user is responsible for ensuring that the sequence of
+individual pushes makes sense.  For example, if `push.default = simple`
+is set and the current branch has no upstream configured, then
+`git push r1` may fail.  `git push all-remotes` will fail in the same
+way, on whichever member remote triggers the condition first.  Setting
+`push.default = current` or supplying explicit refspecs is recommended
+when pushing to a remote group.
+
+Similarly, if `--force-with-lease` is given without an explicit expected
+commit, Git will guess the expected commit for each remote independently
+from that remote's own remote-tracking branch, the same way it would if
+each push were run separately.  If an explicit commit is given with
+`--force-with-lease=<refname>:<expect>`, that same value is forwarded
+to every member remote, as if each of
+`git push --force-with-lease=<refname>:<expect> r1`,
+`git push --force-with-lease=<refname>:<expect> r2`, ...,
+`git push --force-with-lease=<refname>:<expect> rN` had been invoked.
+
+Each member remote is pushed using its own push mapping configuration
+(`remote.<name>.push`), so a refspec that maps differently on r1 than
+on r2 is resolved correctly for each one.
+
 OUTPUT
 ------
 
diff --git a/builtin/push.c b/builtin/push.c
index 5b6cebbb85..33de769a33 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -542,7 +542,6 @@ static int git_push_config(const char *k, const char *v,
 
 	return git_default_config(k, v, ctx, NULL);
 }
-
 int cmd_push(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -551,12 +550,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
+	int base_flags;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP;
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -619,39 +619,45 @@ int cmd_push(int argc,
 	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
 		flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
 
-	if (tags)
-		refspec_append(&rs, "refs/tags/*");
-
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
-	}
-
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
-
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
-
-	if (flags & TRANSPORT_PUSH_ALL) {
-		if (argc >= 2)
-			die(_("--all can't be combined with refspecs"));
-	}
-	if (flags & TRANSPORT_PUSH_MIRROR) {
-		if (argc >= 2)
-			die(_("--mirror can't be combined with refspecs"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group)) {
+			/*
+			 * Not a configured remote name or group name.
+			 * Try treating it as a direct URL or path, e.g.
+			 *   git push /tmp/foo.git
+			 *   git push https://github.com/user/repo.git
+			 * pushremote_get() creates an anonymous remote
+			 * from the URL so the loop below can handle it
+			 * identically to a named remote.
+			 */
+			struct remote *r = pushremote_get(repo);
+			if (!r)
+				die(_("bad repository '%s'"), repo);
+			string_list_append(&remote_group, r->name);
+		}
+	} else {
+		struct remote *r = pushremote_get(NULL);
+		if (!r)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"
+			    "\n"
+			    "To push to multiple remotes at once, configure a remote group using\n"
+			    "\n"
+			    "    git config remotes.<groupname> \"<remote1> <remote2>\"\n"
+			    "\n"
+			    "and then push using the group name\n"
+			    "\n"
+			    "    git push <groupname>\n"));
+		string_list_append(&remote_group, r->name);
 	}
 
 	if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -661,10 +667,60 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	/*
+	 * Push to each remote in remote_group. For a plain "git push <remote>"
+	 * or a default push, remote_group has exactly one entry and the loop
+	 * runs once — there is nothing structurally special about that case.
+	 * For a group, the loop runs once per member remote.
+	 *
+	 * Mirror detection and the --mirror/--all + refspec conflict checks
+	 * are done per remote inside the loop. A remote configured with
+	 * remote.NAME.mirror=true implies mirror mode for that remote only —
+	 * other non-mirror remotes in the same group are unaffected.
+	 *
+	 * rs is rebuilt from scratch for each remote so that per-remote push
+	 * mappings (remote.NAME.push config) are resolved against the correct
+	 * remote. iter_flags is derived from a clean snapshot of flags taken
+	 * before the loop so that a mirror remote cannot bleed
+	 * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the
+	 * same group.
+	 */
+	base_flags = flags;
+	for (int i = 0; i < remote_group.nr; i++) {
+		int iter_flags = base_flags;
+		struct remote *r = pushremote_get(remote_group.items[i].string);
+		if (!r)
+			die(_("no such remote or remote group: %s"),
+			    remote_group.items[i].string);
+
+		if (r->mirror)
+			iter_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+		if (iter_flags & TRANSPORT_PUSH_ALL) {
+			if (argc >= 2)
+				die(_("--all can't be combined with refspecs"));
+		}
+		if (iter_flags & TRANSPORT_PUSH_MIRROR) {
+			if (argc >= 2)
+				die(_("--mirror can't be combined with refspecs"));
+		}
+
+		refspec_clear(&rs);
+		rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+		if (tags)
+			refspec_append(&rs, "refs/tags/*");
+		if (argc > 0)
+			set_refspecs(argv + 1, argc - 1, r);
+
+		rc |= do_push(iter_flags, push_options, r);
+	}
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
diff --git a/t/meson.build b/t/meson.build
index 6d91470ebc..eb123f456a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -697,6 +697,7 @@ integration_tests = [
   't5563-simple-http-auth.sh',
   't5564-http-proxy.sh',
   't5565-push-multiple.sh',
+  't5566-push-group.sh',
   't5570-git-daemon.sh',
   't5571-pre-push-hook.sh',
   't5572-pull-submodule.sh',
diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
new file mode 100755
index 0000000000..9e0d378f2a
--- /dev/null
+++ b/t/t5566-push-group.sh
@@ -0,0 +1,95 @@
+#!/bin/sh
+
+test_description='push to remote group'
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	for i in 1 2 3
+	do
+		git init --bare dest-$i.git &&
+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
+		return 1
+	done &&
+	test_tick &&
+	git commit --allow-empty -m "initial" &&
+	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
+	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
+	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
+	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
+	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
+	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
+	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
+'
+
+test_expect_success 'push to remote group pushes to all members' '
+	git push all-remotes HEAD:refs/heads/main &&
+	j= &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git for-each-ref >actual-$i &&
+		if test -n "$j"
+		then
+			test_cmp actual-$j actual-$i
+		else
+			cat actual-$i
+		fi &&
+		j=$i ||
+		return 1
+	done
+'
+
+test_expect_success 'push second commit to group updates all members' '
+	test_tick &&
+	git commit --allow-empty -m "second" &&
+	git push all-remotes HEAD:refs/heads/main &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >hash-$i ||
+		return 1
+	done &&
+	test_cmp hash-1 hash-2 &&
+	test_cmp hash-2 hash-3
+'
+
+test_expect_success 'push to single remote in group does not affect others' '
+	test_tick &&
+	git commit --allow-empty -m "third" &&
+	git push remote-1 HEAD:refs/heads/main &&
+	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
+	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
+	! test_cmp hash-after-1 hash-after-2
+'
+
+test_expect_success 'push to nonexistent group fails with error' '
+	test_must_fail git push no-such-group HEAD:refs/heads/main
+'
+
+test_expect_success 'push explicit refspec to group' '
+	test_tick &&
+	git commit --allow-empty -m "fourth" &&
+	git push all-remotes HEAD:refs/heads/other &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||
+		return 1
+	done &&
+	test_cmp other-hash-1 other-hash-2 &&
+	test_cmp other-hash-2 other-hash-3
+'
+
+test_expect_success 'mirror remote in group with refspec fails' '
+	git config set remote.remote-1.mirror true &&
+	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
+	grep "mirror" err &&
+	git config unset remote.remote-1.mirror
+'
+test_expect_success 'push.default=current works with group push' '
+	git config set push.default current &&
+	test_tick &&
+	git commit --allow-empty -m "fifth" &&
+	git push all-remotes &&
+	git config unset push.default
+'
+
+test_done
-- 
2.53.0
Junio C HamanoMar 18, 2026, 20:57 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v2 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 5 quoted lines
> A known interaction: push.default = simple will die when the current
> branch has no upstream configured, because setup_default_push_refspecs()
> requires an upstream for that mode. Users pushing to a group should set
> push.default = current or supply explicit refspecs. This is consistent
> with how fetch handles default refspec resolution per remote.

Even if you are not pushing to a group but to a single remote, default=simple would die when the current branch has no upstream configured, so I do not know what the point of singling "default=simple" out is in the above paragraph. For that matter, is "default=current" so special and would it be the only way, other than giving an explicit refspec, to make the push succeed? Wouldn't default=matching, for example, work equally well?

Show 15 quoted lines
> +When a group name is given as the `<repository>` argument, the push is
> +performed to each member remote in turn.  The defining principle is:
> +
> +	git push <options> all-remotes <args>
> +
> +is exactly equivalent to:
> +
> +	git push <options> r1 <args>
> +	git push <options> r2 <args>
> +	...
> +	git push <options> rN <args>
> +
> +where r1, r2, ..., rN are the members of `all-remotes`.  No special
> +behaviour is added or removed — the group is purely a shorthand for
> +running the same push command against each member remote individually.

One thing this does not make it clear is if we abort upon the first failure, or even after some fail we still attempt to push to the rest of the remotes in the group.

> +Similarly, if `--force-with-lease` is given without an explicit expected
> +commit, Git will guess the expected commit for each remote independently
> +from that remote's own remote-tracking branch, the same way it would if
> +each push were run separately.

Understandable (even though it would be obvious from the "exactly equivalent to" above).

Show 6 quoted lines
> If an explicit commit is given with
> +`--force-with-lease=<refname>:<expect>`, that same value is forwarded
> +to every member remote, as if each of
> +`git push --force-with-lease=<refname>:<expect> r1`,
> +`git push --force-with-lease=<refname>:<expect> r2`, ...,
> +`git push --force-with-lease=<refname>:<expect> rN` had been invoked.
Understandable (ditto).
Show 12 quoted lines
> diff --git a/builtin/push.c b/builtin/push.c
> index 5b6cebbb85..33de769a33 100644
> --- a/builtin/push.c
> +++ b/builtin/push.c
> @@ -542,7 +542,6 @@ static int git_push_config(const char *k, const char *v,
>  
>  	return git_default_config(k, v, ctx, NULL);
>  }
> -
>  int cmd_push(int argc,
>  	     const char **argv,
>  	     const char *prefix,
You didn't want to include this hunk, I presume?
Thanks.
Junio C HamanoMar 18, 2026, 21:57 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 27 quoted lines
> This RFC series adds support for `git push` to accept a remote group
> name (as configured via `remotes.<name>` in config) in addition to a
> single remote name, mirroring the behaviour that `git fetch` has
> supported for some time.
>
> A user with multiple remotes configured as a group can now do:
>
>     git push all-remotes
>
> instead of pushing to each remote individually, in the same way that:
>
>     git fetch all-remotes
>
> already works.
>
> The series is split into two patches:
>
>   - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
>     `remote_group_data` struct out of builtin/fetch.c and into
>     remote.c/remote.h, making them part of the public remote API.
>
>   - Patch 2 extends builtin/push.c to use the newly public
>     `add_remote_or_group()` to resolve the repository argument as
>     either a single remote or a group, and pushes to each member of
>     the group in turn.
>
> RFC notes and open questions:
>   - The current implementation pushes to group members sequentially.
>   - push.default = simple interacts poorly with group pushes when the
>   - force-with-lease semantics across a group push are currently
I am indifferent; comments from others very much welcomed.
>
>   - I will also add the tests and documentations in the next iterations
Hmm, is this still valid?
Show 17 quoted lines
> Changes in v2:
>   - Remove UNUSED from the declaration in remote.h (patch 1).
>   - Drop the persistent `remote` variable from cmd_push entirely
>     (patch 2). Following Junio's suggestion, the default remote
>     case now folds into remote_group so the single-remote and
>     group cases are handled by a single unified loop. There is
>     no longer any structural difference between pushing to one
>     remote and pushing to a group — a singleton is just a group
>     of one.
>   - Move the --mirror+refspec and --all+refspec conflict checks
>     inside the loop so they are evaluated per remote.
>   - Add a URL/path fallback so that direct path arguments like
>       git push /tmp/foo.git
>     continue to work correctly after the remote resolution
>     change.
>   - Add a test script t5528-push-group.sh covering the new
>     group push behaviour.

I think you added 5566 instead of 5528 (the latter of which is already used by another test).

Show 21 quoted lines
>   - Update Documentation/git-push.adoc: DESCRIPTION, the
>     <repository> argument description, and a new REMOTE GROUPS
>     section documenting the defining principle that
>       git push <options> all-remotes <args>
>     is exactly equivalent to running git push <options> r$i <args>
>     for each member remote independently.
>
>
> Usman Akinyemi (2):
>   remote: move remote group resolution to remote.c
>   push: support pushing to a remote group
>
>  Documentation/git-push.adoc |  76 +++++++++++++++++++---
>  builtin/fetch.c             |  42 ------------
>  builtin/push.c              | 124 ++++++++++++++++++++++++++----------
>  remote.c                    |  37 +++++++++++
>  remote.h                    |  12 ++++
>  t/meson.build               |   1 +
>  t/t5566-push-group.sh       |  95 +++++++++++++++++++++++++++
>  7 files changed, 303 insertions(+), 84 deletions(-)
>  create mode 100755 t/t5566-push-group.sh
Junio C HamanoMar 18, 2026, 21:58 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v2 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
> When the argument resolves to a single remote the behaviour is
> identical to before. When it resolves to a group, each member remote
I find this hard to read without a comma after "to a single remote".
Junio C HamanoMar 18, 2026, 22:25 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v2 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 29 quoted lines
> diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
> new file mode 100755
> index 0000000000..9e0d378f2a
> --- /dev/null
> +++ b/t/t5566-push-group.sh
> @@ -0,0 +1,95 @@
> +#!/bin/sh
> +
> +test_description='push to remote group'
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'setup' '
> +	for i in 1 2 3
> +	do
> +		git init --bare dest-$i.git &&
> +		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
> +		return 1
> +	done &&
> +	test_tick &&
> +	git commit --allow-empty -m "initial" &&
> +	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
> +	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
> +	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
> +	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
> +	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
> +	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
> +	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
> +'

So we have three remotes, dest-{1,2,3}.git/ that are all bare, and a remote group "all-remotes" that name them. Is there a reason why you want to use an unborn HEAD?

> +test_expect_success 'push to remote group pushes to all members' '
> +	git push all-remotes HEAD:refs/heads/main &&

Our "push" exits with 0 status. How would we make sure we pushed correctly?

> +	j= &&
> +	for i in 1 2 3
> +	do
> +		git -C dest-$i.git for-each-ref >actual-$i &&
We grab dest-$i's refs to actual-$i
> +		if test -n "$j"
> +		then
> +			test_cmp actual-$j actual-$i
and make sure if refs in dest-N differ from dest-(N-1)'s refs.
> +		else
> +			cat actual-$i

of course, the first one has nothing to compare against, so we get a debugging "cat" for it.

> +		fi &&
> +		j=$i ||
> +		return 1

But does this loop test what we really want to make sure? You could write your "group push" to push one commit less than what was asked to push out to all remotes, and they will match with each other to pass the above test, but it would be different from our original.

Don't we know the exact state of refs in these dest-$i.git repositories? If we do, then

    printf "%s commit\trefs/heads/main\n" >expect &&
    for i in 1 2 3
    do
	git -C dest-$i.git for-each-ref >actual &&
	test_cmp expect actual || return 1
    done
perhaps?
Show 12 quoted lines
> +test_expect_success 'push second commit to group updates all members' '
> +	test_tick &&
> +	git commit --allow-empty -m "second" &&
> +	git push all-remotes HEAD:refs/heads/main &&
> +	for i in 1 2 3
> +	do
> +		git -C dest-$i.git rev-parse refs/heads/main >hash-$i ||
> +		return 1
> +	done &&
> +	test_cmp hash-1 hash-2 &&
> +	test_cmp hash-2 hash-3
> +'

Again, the primary thing we are interested in is that dest-*.git has a copy of what we pushed. They may be identical to each other among themselves but they still could be different from what we pushed, and that is something we want to catch, no?

    git rev-parse refs/heads/main >expect &&
    for i in 1 2 3
    do
	git -C dest-$i.git rev-parse refs/heads/main >actual &&
	test_cmp expect actual || return 1
    done
Show 9 quoted lines
> +
> +test_expect_success 'push to single remote in group does not affect others' '
> +	test_tick &&
> +	git commit --allow-empty -m "third" &&
> +	git push remote-1 HEAD:refs/heads/main &&
> +	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
> +	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
> +	! test_cmp hash-after-1 hash-after-2
> +'
Obviously correct.
> +test_expect_success 'push to nonexistent group fails with error' '
> +	test_must_fail git push no-such-group HEAD:refs/heads/main
> +'

Obviously correct---we probably should already have a test to see that a push to nonexistent remote repository fails (missing one you cannot even tell if it is a single remote or a group), in which case this is not even needed.

> +test_expect_success 'push explicit refspec to group' '
> +	test_tick &&
> +	git commit --allow-empty -m "fourth" &&
> +	git push all-remotes HEAD:refs/heads/other &&

Didn't we do this already? We did so with 'main' into dest-*.git that did not know anything about 'main' (after its HEAD repointed to a missing branch).

Show 13 quoted lines
> +	for i in 1 2 3
> +	do
> +		git -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||
> +		return 1
> +	done &&
> +	test_cmp other-hash-1 other-hash-2 &&
> +	test_cmp other-hash-2 other-hash-3
> +'
> +
> +test_expect_success 'mirror remote in group with refspec fails' '
> +	git config set remote.remote-1.mirror true &&
> +	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
> +	grep "mirror" err &&
test_grep??
Show 11 quoted lines
> +	git config unset remote.remote-1.mirror
> +'
> +test_expect_success 'push.default=current works with group push' '
> +	git config set push.default current &&
> +	test_tick &&
> +	git commit --allow-empty -m "fifth" &&
> +	git push all-remotes &&
> +	git config unset push.default
> +'
> +
> +test_done
Usman AkinyemiMar 18, 2026, 23:13 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups

Show 6 quoted lines
>
> >   - The current implementation pushes to group members sequentially.
> >   - push.default = simple interacts poorly with group pushes when the
> >   - force-with-lease semantics across a group push are currently
>
> I am indifferent; comments from others very much welcomed.
Yeah.
Show 5 quoted lines
>
> >
> >   - I will also add the tests and documentations in the next iterations
>
> Hmm, is this still valid?
Nope, I already did that, this was from v1.
>
Show 7 quoted lines
> >     continue to work correctly after the remote resolution
> >     change.
> >   - Add a test script t5528-push-group.sh covering the new
> >     group push behaviour.
>
> I think you added 5566 instead of 5528 (the latter of which is
> already used by another test).
Mistake.
Junio C HamanoMar 19, 2026, 17:02 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v2 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
> +	for (int i = 0; i < remote_group.nr; i++) {

remote_group.nr is of size_t, so the compiler will complain about this comparison that stops a platform natural integer approaching the limit from below for type and signedness mismatch, even though it would be insane to try defining a remote group whose size would not fit platform natural integer. A workaround is obvious.

    "int i = 0" -> "size_t i = 0"
Usman AkinyemiMar 25, 2026, 18:42 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH v2 2/2] push: support pushing to a remote group

On Thu, Mar 19, 2026 at 10:32 PM Junio C Hamano <gitster@pobox.com> wrote:
>     "int i = 0" -> "size_t i = 0"
Thanks for the review. I am sending a new version that addresses all these.
Usman AkinyemiMar 25, 2026, 19:09 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v3 0/2] push: add support for pushing to remote groups

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into two patches:
  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
Changes in v3:
- Clarify documentation for remote group pushes:
  * describe behavior on partial failures (rejection vs fatal errors)
  * emphasize that group push is equivalent to running independent
    pushes to each member remote
- Simplify tests:
  * remove redundant comparisons between remotes
  * verify pushed commits against the expected value using `rev-parse`
- Add tests for failure scenarios:
  * ensure push continues on non-fast-forward rejection
  * ensure push stops on fatal transport errors
- Use `size_t` for loop index to match type of `remote_group.nr`
  and avoid compiler warnings
Range-diff v2 -> v3:
1:  dd370a19e7 = 1:  dd370a19e7 remote: move remote group resolution to remote.c
2:  ba5801cee1 ! 2:  6a7957e61c push: support pushing to a remote group
    @@ Commit message
     
             git push <group>
     
    -    When the argument resolves to a single remote the behaviour is
    +    When the argument resolves to a single remote, the behaviour is
         identical to before. When it resolves to a group, each member remote
         is pushed in sequence.
     
    @@ Commit message
         a copy of the flags, so that a mirror remote in the group cannot set
         TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.
     
    -    A known interaction: push.default = simple will die when the current
    -    branch has no upstream configured, because setup_default_push_refspecs()
    -    requires an upstream for that mode. Users pushing to a group should set
    -    push.default = current or supply explicit refspecs. This is consistent
    -    with how fetch handles default refspec resolution per remote.
    -
         Suggested-by: Junio C Hamano <gitster@pobox.com>
         Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
     
    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`
     +behaviour is added or removed — the group is purely a shorthand for
     +running the same push command against each member remote individually.
     +
    ++The behaviour upon failure depends on the kind of error encountered:
    ++
    ++If a member remote rejects the push, for example due to a
    ++non-fast-forward update, force needed but not given, an existing tag,
    ++or a server-side hook refusing a ref, Git reports the error and continues
    ++pushing to the remaining remotes in the group. The overall exit code is
    ++non-zero if any member push fails.
    ++
    ++If a member remote cannot be contacted at all, for example because the
    ++repository does not exist, authentication fails, or the network is
    ++unreachable, the push stops at that point and the remaining remotes
    ++are not attempted.
    ++
     +This means the user is responsible for ensuring that the sequence of
    -+individual pushes makes sense.  For example, if `push.default = simple`
    -+is set and the current branch has no upstream configured, then
    -+`git push r1` may fail.  `git push all-remotes` will fail in the same
    -+way, on whichever member remote triggers the condition first.  Setting
    -+`push.default = current` or supplying explicit refspecs is recommended
    -+when pushing to a remote group.
    -+
    -+Similarly, if `--force-with-lease` is given without an explicit expected
    -+commit, Git will guess the expected commit for each remote independently
    -+from that remote's own remote-tracking branch, the same way it would if
    -+each push were run separately.  If an explicit commit is given with
    -+`--force-with-lease=<refname>:<expect>`, that same value is forwarded
    -+to every member remote, as if each of
    -+`git push --force-with-lease=<refname>:<expect> r1`,
    -+`git push --force-with-lease=<refname>:<expect> r2`, ...,
    -+`git push --force-with-lease=<refname>:<expect> rN` had been invoked.
    -+
    -+Each member remote is pushed using its own push mapping configuration
    -+(`remote.<name>.push`), so a refspec that maps differently on r1 than
    -+on r2 is resolved correctly for each one.
    ++individual pushes makes sense. If `git push r1`` would fail for a given
    ++set of options and arguments, then `git push all-remotes` will fail in
    ++the same way when it reaches r1. The group push does not do anything
    ++special to make a failing individual push succeed.
     +
      OUTPUT
      ------
      
     
      ## builtin/push.c ##
    -@@ builtin/push.c: static int git_push_config(const char *k, const char *v,
    - 
    - 	return git_default_config(k, v, ctx, NULL);
    - }
    --
    - int cmd_push(int argc,
    - 	     const char **argv,
    - 	     const char *prefix,
     @@ builtin/push.c: int cmd_push(int argc,
      	int flags = 0;
      	int tags = 0;
    @@ builtin/push.c: int cmd_push(int argc,
     +	 * same group.
     +	 */
     +	base_flags = flags;
    -+	for (int i = 0; i < remote_group.nr; i++) {
    ++	for (size_t i = 0; i < remote_group.nr; i++) {
     +		int iter_flags = base_flags;
     +		struct remote *r = pushremote_get(remote_group.items[i].string);
     +		if (!r)
    @@ t/t5566-push-group.sh (new)
     +test_expect_success 'setup' '
     +	for i in 1 2 3
     +	do
    -+		git init --bare dest-$i.git &&
    -+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
    -+		return 1
    ++		git init --bare dest-$i.git || return 1
     +	done &&
     +	test_tick &&
     +	git commit --allow-empty -m "initial" &&
    @@ t/t5566-push-group.sh (new)
     +	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
     +'
     +
    -+test_expect_success 'push to remote group pushes to all members' '
    ++test_expect_success 'push to remote group updates all members correctly' '
     +	git push all-remotes HEAD:refs/heads/main &&
    -+	j= &&
    ++	git rev-parse HEAD >expect &&
     +	for i in 1 2 3
     +	do
    -+		git -C dest-$i.git for-each-ref >actual-$i &&
    -+		if test -n "$j"
    -+		then
    -+			test_cmp actual-$j actual-$i
    -+		else
    -+			cat actual-$i
    -+		fi &&
    -+		j=$i ||
    ++		git -C dest-$i.git rev-parse refs/heads/main >actual ||
     +		return 1
    ++		test_cmp expect actual || return 1
     +	done
     +'
     +
    @@ t/t5566-push-group.sh (new)
     +	test_tick &&
     +	git commit --allow-empty -m "second" &&
     +	git push all-remotes HEAD:refs/heads/main &&
    ++	git rev-parse HEAD >expect &&
     +	for i in 1 2 3
     +	do
    -+		git -C dest-$i.git rev-parse refs/heads/main >hash-$i ||
    ++		git -C dest-$i.git rev-parse refs/heads/main >actual ||
     +		return 1
    -+	done &&
    -+	test_cmp hash-1 hash-2 &&
    -+	test_cmp hash-2 hash-3
    ++		test_cmp expect actual || return 1
    ++	done
     +'
     +
     +test_expect_success 'push to single remote in group does not affect others' '
    @@ t/t5566-push-group.sh (new)
     +	! test_cmp hash-after-1 hash-after-2
     +'
     +
    -+test_expect_success 'push to nonexistent group fails with error' '
    -+	test_must_fail git push no-such-group HEAD:refs/heads/main
    -+'
    -+
    -+test_expect_success 'push explicit refspec to group' '
    -+	test_tick &&
    -+	git commit --allow-empty -m "fourth" &&
    -+	git push all-remotes HEAD:refs/heads/other &&
    -+	for i in 1 2 3
    -+	do
    -+		git -C dest-$i.git rev-parse refs/heads/other >other-hash-$i ||
    -+		return 1
    -+	done &&
    -+	test_cmp other-hash-1 other-hash-2 &&
    -+	test_cmp other-hash-2 other-hash-3
    -+'
    -+
     +test_expect_success 'mirror remote in group with refspec fails' '
     +	git config set remote.remote-1.mirror true &&
     +	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
    -+	grep "mirror" err &&
    ++	test_grep "mirror" err &&
     +	git config unset remote.remote-1.mirror
     +'
     +test_expect_success 'push.default=current works with group push' '
    @@ t/t5566-push-group.sh (new)
     +	git config unset push.default
     +'
     +
    ++test_expect_success 'push continues past rejection to remaining remotes' '
    ++	for i in c1 c2 c3
    ++	do
    ++		git init --bare dest-$i.git || return 1
    ++	done &&
    ++	git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
    ++	git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
    ++	git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
    ++	git config set remotes.continue-group "c1 c2 c3" &&
    ++
    ++	test_tick &&
    ++	git commit --allow-empty -m "base for continue test" &&
    ++
    ++	# initial sync
    ++	git push continue-group HEAD:refs/heads/main &&
    ++
    ++  # advance c2 independently
    ++  git clone dest-c2.git tmp-c2 &&
    ++  (
    ++    cd tmp-c2 &&
    ++    git checkout -b main origin/main &&
    ++    test_commit c2_independent &&
    ++    git push origin HEAD:refs/heads/main
    ++  ) &&
    ++  rm -rf tmp-c2 &&
    ++
    ++	test_tick &&
    ++	git commit --allow-empty -m "local diverging commit" &&
    ++
    ++	# push: c2 rejects, others succeed
    ++	test_must_fail git push continue-group HEAD:refs/heads/main &&
    ++
    ++	git rev-parse HEAD >expect &&
    ++	git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
    ++	git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
    ++	test_cmp expect actual-c1 &&
    ++	test_cmp expect actual-c3 &&
    ++
    ++	# c2 should not have the new commit
    ++	git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
    ++	! test_cmp expect actual-c2
    ++'
    ++
    ++test_expect_success 'fatal connection error stops remaining remotes' '
    ++	for i in f1 f2 f3
    ++	do
    ++		git init --bare dest-$i.git || return 1
    ++	done &&
    ++	git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
    ++	git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
    ++	git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
    ++	git config set remotes.fatal-group "f1 f2 f3" &&
    ++
    ++	test_tick &&
    ++	git commit --allow-empty -m "base for fatal test" &&
    ++
    ++	# initial sync
    ++	git push fatal-group HEAD:refs/heads/main &&
    ++
    ++	# break f2
    ++	git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
    ++
    ++	test_tick &&
    ++	git commit --allow-empty -m "after fatal setup" &&
    ++
    ++	test_must_fail git push fatal-group HEAD:refs/heads/main &&
    ++
    ++	git rev-parse HEAD >expect &&
    ++	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
    ++	test_cmp expect actual-f1 &&
    ++
    ++	# f3 should not be updated
    ++	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
    ++	! test_cmp expect actual-f3 &&
    ++
    ++	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
    ++'
    ++
     +test_done
Usman Akinyemi (2):
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 Documentation/git-push.adoc |  73 ++++++++++++++++--
 builtin/fetch.c             |  42 ----------
 builtin/push.c              | 123 +++++++++++++++++++++--------
 remote.c                    |  37 +++++++++
 remote.h                    |  12 +++
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++
 7 files changed, 355 insertions(+), 83 deletions(-)
 create mode 100755 t/t5566-push-group.sh
-- 
2.48.0.rc0.4242.g73eb647d24.dirty
Usman AkinyemiMar 25, 2026, 19:09 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v3 1/2] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index 573c295241..6e56c484bf 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2135,48 +2135,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index f6980dc656..75fdb4019d 100644
--- a/remote.c
+++ b/remote.c
@@ -2108,6 +2108,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index fc052945ee..8ff2bd88fa 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+                    const struct config_context *ctx,
+                    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.48.0.rc0.4242.g73eb647d24.dirty
Usman AkinyemiMar 25, 2026, 19:09 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v3 2/2] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote, the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 Documentation/git-push.adoc |  73 ++++++++++++++++--
 builtin/push.c              | 123 +++++++++++++++++++++--------
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++
 4 files changed, 306 insertions(+), 41 deletions(-)
 create mode 100755 t/t5566-push-group.sh
Show changes to 4 files +306 −41

Documentation/git-push.adoc, builtin/push.c, t/meson.build, t/t5566-push-group.sh

diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
index e5ba3a6742..b7f617a290 100644
--- a/Documentation/git-push.adoc
+++ b/Documentation/git-push.adoc
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
 
 DESCRIPTION
 -----------
-
-Updates one or more branches, tags, or other references in a remote
-repository from your local repository, and sends all necessary data
-that isn't already on the remote.
+Updates one or more branches, tags, or other references in one or more
+remote repositories from your local repository, and sends all necessary
+data that isn't already on the remote.
 
 The simplest way to push is `git push <remote> <branch>`.
 `git push origin main` will push the local `main` branch to the `main`
 branch on the remote named `origin`.
 
-The `<repository>` argument defaults to the upstream for the current branch,
-or `origin` if there's no configured upstream.
+You can also push to multiple remotes at once by using a remote group.
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "origin gitlab backup"
+
+Then `git push all-remotes` will push to `origin`, `gitlab`, and
+`backup` in turn, as if you had run `git push` against each one
+individually.  Each remote is pushed independently using its own
+push mapping configuration. There is a `remotes.<group>` entry in
+the configuration file. (See linkgit:git-config[1]).
+
+The `<repository>` argument defaults to the upstream for the current
+branch, or `origin` if there's no configured upstream.
 
 To decide which branches, tags, or other refs to push, Git uses
 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
 _<repository>_::
 	The "remote" repository that is the destination of a push
 	operation.  This parameter can be either a URL
-	(see the section <<URLS,GIT URLS>> below) or the name
-	of a remote (see the section <<REMOTES,REMOTES>> below).
+	(see the section <<URLS,GIT URLS>> below), the name
+	of a remote (see the section <<REMOTES,REMOTES>> below),
+	or the name of a remote group
+	(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
 
 `<refspec>...`::
 	Specify what destination ref to update with what source object.
@@ -430,6 +443,50 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
 
 include::urls-remotes.adoc[]
 
+[[REMOTE-GROUPS]]
+REMOTE GROUPS
+-------------
+
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "r1 r2 r3"
+
+When a group name is given as the `<repository>` argument, the push is
+performed to each member remote in turn.  The defining principle is:
+
+	git push <options> all-remotes <args>
+
+is exactly equivalent to:
+
+	git push <options> r1 <args>
+	git push <options> r2 <args>
+	...
+	git push <options> rN <args>
+
+where r1, r2, ..., rN are the members of `all-remotes`.  No special
+behaviour is added or removed — the group is purely a shorthand for
+running the same push command against each member remote individually.
+
+The behaviour upon failure depends on the kind of error encountered:
+
+If a member remote rejects the push, for example due to a
+non-fast-forward update, force needed but not given, an existing tag,
+or a server-side hook refusing a ref, Git reports the error and continues
+pushing to the remaining remotes in the group. The overall exit code is
+non-zero if any member push fails.
+
+If a member remote cannot be contacted at all, for example because the
+repository does not exist, authentication fails, or the network is
+unreachable, the push stops at that point and the remaining remotes
+are not attempted.
+
+This means the user is responsible for ensuring that the sequence of
+individual pushes makes sense. If `git push r1`` would fail for a given
+set of options and arguments, then `git push all-remotes` will fail in
+the same way when it reaches r1. The group push does not do anything
+special to make a failing individual push succeed.
+
 OUTPUT
 ------
 
diff --git a/builtin/push.c b/builtin/push.c
index 5b6cebbb85..4a19d3879d 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -551,12 +551,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
+	int base_flags;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP;
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -619,39 +620,45 @@ int cmd_push(int argc,
 	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
 		flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
 
-	if (tags)
-		refspec_append(&rs, "refs/tags/*");
-
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
-	}
-
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
-
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
-
-	if (flags & TRANSPORT_PUSH_ALL) {
-		if (argc >= 2)
-			die(_("--all can't be combined with refspecs"));
-	}
-	if (flags & TRANSPORT_PUSH_MIRROR) {
-		if (argc >= 2)
-			die(_("--mirror can't be combined with refspecs"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group)) {
+			/*
+			 * Not a configured remote name or group name.
+			 * Try treating it as a direct URL or path, e.g.
+			 *   git push /tmp/foo.git
+			 *   git push https://github.com/user/repo.git
+			 * pushremote_get() creates an anonymous remote
+			 * from the URL so the loop below can handle it
+			 * identically to a named remote.
+			 */
+			struct remote *r = pushremote_get(repo);
+			if (!r)
+				die(_("bad repository '%s'"), repo);
+			string_list_append(&remote_group, r->name);
+		}
+	} else {
+		struct remote *r = pushremote_get(NULL);
+		if (!r)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"
+			    "\n"
+			    "To push to multiple remotes at once, configure a remote group using\n"
+			    "\n"
+			    "    git config remotes.<groupname> \"<remote1> <remote2>\"\n"
+			    "\n"
+			    "and then push using the group name\n"
+			    "\n"
+			    "    git push <groupname>\n"));
+		string_list_append(&remote_group, r->name);
 	}
 
 	if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -661,10 +668,60 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	/*
+	 * Push to each remote in remote_group. For a plain "git push <remote>"
+	 * or a default push, remote_group has exactly one entry and the loop
+	 * runs once — there is nothing structurally special about that case.
+	 * For a group, the loop runs once per member remote.
+	 *
+	 * Mirror detection and the --mirror/--all + refspec conflict checks
+	 * are done per remote inside the loop. A remote configured with
+	 * remote.NAME.mirror=true implies mirror mode for that remote only —
+	 * other non-mirror remotes in the same group are unaffected.
+	 *
+	 * rs is rebuilt from scratch for each remote so that per-remote push
+	 * mappings (remote.NAME.push config) are resolved against the correct
+	 * remote. iter_flags is derived from a clean snapshot of flags taken
+	 * before the loop so that a mirror remote cannot bleed
+	 * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the
+	 * same group.
+	 */
+	base_flags = flags;
+	for (size_t i = 0; i < remote_group.nr; i++) {
+		int iter_flags = base_flags;
+		struct remote *r = pushremote_get(remote_group.items[i].string);
+		if (!r)
+			die(_("no such remote or remote group: %s"),
+			    remote_group.items[i].string);
+
+		if (r->mirror)
+			iter_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+		if (iter_flags & TRANSPORT_PUSH_ALL) {
+			if (argc >= 2)
+				die(_("--all can't be combined with refspecs"));
+		}
+		if (iter_flags & TRANSPORT_PUSH_MIRROR) {
+			if (argc >= 2)
+				die(_("--mirror can't be combined with refspecs"));
+		}
+
+		refspec_clear(&rs);
+		rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+		if (tags)
+			refspec_append(&rs, "refs/tags/*");
+		if (argc > 0)
+			set_refspecs(argv + 1, argc - 1, r);
+
+		rc |= do_push(iter_flags, push_options, r);
+	}
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
diff --git a/t/meson.build b/t/meson.build
index 6d91470ebc..eb123f456a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -697,6 +697,7 @@ integration_tests = [
   't5563-simple-http-auth.sh',
   't5564-http-proxy.sh',
   't5565-push-multiple.sh',
+  't5566-push-group.sh',
   't5570-git-daemon.sh',
   't5571-pre-push-hook.sh',
   't5572-pull-submodule.sh',
diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
new file mode 100755
index 0000000000..b9962946c7
--- /dev/null
+++ b/t/t5566-push-group.sh
@@ -0,0 +1,150 @@
+#!/bin/sh
+
+test_description='push to remote group'
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	for i in 1 2 3
+	do
+		git init --bare dest-$i.git &&
+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
+		return 1
+	done &&
+	test_tick &&
+	git commit --allow-empty -m "initial" &&
+	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
+	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
+	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
+	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
+	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
+	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
+	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
+'
+
+test_expect_success 'push to remote group updates all members correctly' '
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push second commit to group updates all members' '
+	test_tick &&
+	git commit --allow-empty -m "second" &&
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push to single remote in group does not affect others' '
+	test_tick &&
+	git commit --allow-empty -m "third" &&
+	git push remote-1 HEAD:refs/heads/main &&
+	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
+	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
+	! test_cmp hash-after-1 hash-after-2
+'
+
+test_expect_success 'mirror remote in group with refspec fails' '
+	git config set remote.remote-1.mirror true &&
+	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "mirror" err &&
+	git config unset remote.remote-1.mirror
+'
+test_expect_success 'push.default=current works with group push' '
+	git config set push.default current &&
+	test_tick &&
+	git commit --allow-empty -m "fifth" &&
+	git push all-remotes &&
+	git config unset push.default
+'
+
+test_expect_success 'push continues past rejection to remaining remotes' '
+	for i in c1 c2 c3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
+	git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
+	git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
+	git config set remotes.continue-group "c1 c2 c3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for continue test" &&
+
+	# initial sync
+	git push continue-group HEAD:refs/heads/main &&
+
+  # advance c2 independently
+  git clone dest-c2.git tmp-c2 &&
+  (
+    cd tmp-c2 &&
+    git checkout -b main origin/main &&
+    test_commit c2_independent &&
+    git push origin HEAD:refs/heads/main
+  ) &&
+  rm -rf tmp-c2 &&
+
+	test_tick &&
+	git commit --allow-empty -m "local diverging commit" &&
+
+	# push: c2 rejects, others succeed
+	test_must_fail git push continue-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+	git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
+	git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
+	test_cmp expect actual-c1 &&
+	test_cmp expect actual-c3 &&
+
+	# c2 should not have the new commit
+	git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
+	! test_cmp expect actual-c2
+'
+
+test_expect_success 'fatal connection error stops remaining remotes' '
+	for i in f1 f2 f3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
+	git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
+	git config set remotes.fatal-group "f1 f2 f3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for fatal test" &&
+
+	# initial sync
+	git push fatal-group HEAD:refs/heads/main &&
+
+	# break f2
+	git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
+
+	test_tick &&
+	git commit --allow-empty -m "after fatal setup" &&
+
+	test_must_fail git push fatal-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
+	test_cmp expect actual-f1 &&
+
+	# f3 should not be updated
+	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
+	! test_cmp expect actual-f3 &&
+
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
+'
+
+test_done
-- 
2.48.0.rc0.4242.g73eb647d24.dirty
Junio C HamanoMar 25, 2026, 19:47 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v3 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 34 quoted lines
> `git fetch` accepts a remote group name (configured via `remotes.<name>`
> in config) and fetches from each member remote. `git push` has no
> equivalent — it only accepts a single remote name.
>
> Teach `git push` to resolve its repository argument through
> `add_remote_or_group()`, which was made public in the previous patch,
> so that a user can push to all remotes in a group with:
>
>     git push <group>
>
> When the argument resolves to a single remote, the behaviour is
> identical to before. When it resolves to a group, each member remote
> is pushed in sequence.
>
> The group push path rebuilds the refspec list (`rs`) from scratch for
> each member remote so that per-remote push mappings configured via
> `remote.<name>.push` are resolved correctly against each specific
> remote. Without this, refspec entries would accumulate across iterations
> and each subsequent remote would receive a growing list of duplicated
> entries.
>
> Mirror detection (`remote->mirror`) is also evaluated per remote using
> a copy of the flags, so that a mirror remote in the group cannot set
> TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.
>
> Suggested-by: Junio C Hamano <gitster@pobox.com>
> Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
> ---
>  Documentation/git-push.adoc |  73 ++++++++++++++++--
>  builtin/push.c              | 123 +++++++++++++++++++++--------
>  t/meson.build               |   1 +
>  t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++
>  4 files changed, 306 insertions(+), 41 deletions(-)
>  create mode 100755 t/t5566-push-group.sh
Show 5 quoted lines
> diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
> index e5ba3a6742..b7f617a290 100644
> --- a/Documentation/git-push.adoc
> +++ b/Documentation/git-push.adoc
> @@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n

All the differences since the previous iteration in the patch to this file makes sense to me, except one thing.

Show 12 quoted lines
> +The behaviour upon failure depends on the kind of error encountered:
> +
> +If a member remote rejects the push, for example due to a
> +non-fast-forward update, force needed but not given, an existing tag,
> +or a server-side hook refusing a ref, Git reports the error and continues
> +pushing to the remaining remotes in the group. The overall exit code is
> +non-zero if any member push fails.
> +
> +If a member remote cannot be contacted at all, for example because the
> +repository does not exist, authentication fails, or the network is
> +unreachable, the push stops at that point and the remaining remotes
> +are not attempted.

I am not convinced that having these two "failure modes" is a good thing; I am not convinced that a single failure mode is better, either, though X-<.

I would personally have designed to mimic exactly like "git push r1; git push r2; ..." would do (not concatenated with "&&" but with ";"), which would mean that there is only one single failure mode that would not affect interactions with any other remotes, but I have no strong arguments to choose that design, other than that it would be easy to explain when we later start supporting pushes to multiple remotes in parallel, where a failure to talk to one remote cannot easily affect interaction with other remotes without getting affected by timing issues.

Show 5 quoted lines
> +This means the user is responsible for ensuring that the sequence of
> +individual pushes makes sense. If `git push r1`` would fail for a given
> +set of options and arguments, then `git push all-remotes` will fail in
> +the same way when it reaches r1. The group push does not do anything
> +special to make a failing individual push succeed.

"when it reaches r1" makes it sound as if the group push then stops after that failure, but that is not what we just read in the two paragraphs about two failure modes.

Junio C HamanoMar 27, 2026, 22:18 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v3 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 36 quoted lines
>  t/meson.build               |   1 +
>  t/t5566-push-group.sh       | 150 ++++++++++++++++++++++++++++++++++++
> ...
> diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
> new file mode 100755
> index 0000000000..b9962946c7
> --- /dev/null
> +++ b/t/t5566-push-group.sh
> @@ -0,0 +1,150 @@
> +#!/bin/sh
> +
> +test_description='push to remote group'
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'setup' '
> +	for i in 1 2 3
> +	do
> +		git init --bare dest-$i.git &&
> +		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
> +		return 1
> +	done &&
> +	test_tick &&
> +	git commit --allow-empty -m "initial" &&
> +	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
> +	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
> +	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
> +	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
> +	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
> +	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
> +	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
> +'
> +
> +test_expect_success 'push to remote group updates all members correctly' '
> +	git push all-remotes HEAD:refs/heads/main &&
> +	git rev-parse HEAD >expect &&

These tests will break rather badly at Git 3.0 boundary, because the default branch name will be 'main' beyond that point.

It can be visible in
    https://github.com/git/git/actions/runs/23667958553/job/68954593675
i.e., linux-breaking-changes job.

I think we can squash in a futureproof fix like this one to the patch.

Show changes to diff +3 −0
diff --git c/t/t5566-push-group.sh w/t/t5566-push-group.sh
index b9962946c7..32b8c82cea 100755
--- c/t/t5566-push-group.sh
+++ w/t/t5566-push-group.sh
@@ -2,6 +2,9 @@
 
 test_description='push to remote group'
 
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
 . ./test-lib.sh
 
 test_expect_success 'setup' '
Usman AkinyemiMar 31, 2026, 22:35 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH v3 2/2] push: support pushing to a remote group

Show 15 quoted lines
>
> I am not convinced that having these two "failure modes" is a good
> thing; I am not convinced that a single failure mode is better,
> either, though X-<.
>
> I would personally have designed to mimic exactly like "git push r1;
> git push r2; ..." would do (not concatenated with "&&" but with
> ";"), which would mean that there is only one single failure mode
> that would not affect interactions with any other remotes, but I
> have no strong arguments to choose that design, other than that it
> would be easy to explain when we later start supporting pushes to
> multiple remotes in parallel, where a failure to talk to one remote
> cannot easily affect interaction with other remotes without getting
> affected by timing issues.
>

I did not think about deciding what approach to pick out of the two, It was just the default i.e the behaviour after adding the changes. I think it makes sense that we have one failure mode incase of multiple remotes in parallel.

Show 9 quoted lines
> > +This means the user is responsible for ensuring that the sequence of
> > +individual pushes makes sense. If `git push r1`` would fail for a given
> > +set of options and arguments, then `git push all-remotes` will fail in
> > +the same way when it reaches r1. The group push does not do anything
> > +special to make a failing individual push succeed.
>
> "when it reaches r1" makes it sound as if the group push then stops
> after that failure, but that is not what we just read in the two
> paragraphs about two failure modes.

hmm, though reading the statement from the beginning makes it clear to me, if `git push r1` will fail, if you `r1` is part of `all-remotes`, if you use `git push all-remotes`, then when it reach `r1`, `r1` will also fail. I think I can make the wording better which should be clear for parallel cases also.

>
Usman AkinyemiMar 31, 2026, 23:45 UTC in reply to Junio C Hamano on lore

Re: [RFC PATCH v3 2/2] push: support pushing to a remote group

Show 10 quoted lines
>
> I would personally have designed to mimic exactly like "git push r1;
> git push r2; ..." would do (not concatenated with "&&" but with
> ";"), which would mean that there is only one single failure mode
> that would not affect interactions with any other remotes, but I
> have no strong arguments to choose that design, other than that it
> would be easy to explain when we later start supporting pushes to
> multiple remotes in parallel, where a failure to talk to one remote
> cannot easily affect interaction with other remotes without getting
> affected by timing issues.

If we want to have one failure mode i.e continue pushing when there is a failure, then, we have to use `run_command` to spawn a child process for each of the push.

Is there any other approach you see here ?
Junio C HamanoApr 1, 2026, 16:56 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v3 2/2] push: support pushing to a remote group

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:
Show 14 quoted lines
>>
>> I would personally have designed to mimic exactly like "git push r1;
>> git push r2; ..." would do (not concatenated with "&&" but with
>> ";"), which would mean that there is only one single failure mode
>> that would not affect interactions with any other remotes, but I
>> have no strong arguments to choose that design, other than that it
>> would be easy to explain when we later start supporting pushes to
>> multiple remotes in parallel, where a failure to talk to one remote
>> cannot easily affect interaction with other remotes without getting
>> affected by timing issues.
> If we want to have one failure mode i.e continue pushing when there is
> a failure,
> then, we have to use `run_command` to spawn a child process for each
> of the push.

Because you would want to avoid hitting a "die()" while pushing to the (N-1)th remote, before you push to the Nth remote?

If there is a "now we have attempted to push to all N remotes, and know the outcome from these N attempts, summarize them and present the result" phase in the program, then you'd need to spawn sub push for N times and then the primary process needs to do the summarizing.

If there isn't any such "post push clean-up" phase, we need N-1 sub pushes and the last one can be done in the primary process. If that is possible, that would be ideal, because it makes N==1 case the same as the traditional "push to a single remote" case.

Usman AkinyemiApr 27, 2026, 14:05 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v3 0/2] push: add support for pushing to remote groups

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into two patches:
  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
Changes in v4:
- Made the multiple push to use child process through `run_command`
  thereby making failure to in pushing to one remote not affect the
  others no matter the kind of failure it is.
- Update the test and the docs to reflect the above.
Range-diff v3 -> v4:
1:  dd370a19e7 = 1:  20ed79546f remote: move remote group resolution to remote.c
2:  6a7957e61c ! 2:  964694e587 push: support pushing to a remote group
    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`
     +behaviour is added or removed — the group is purely a shorthand for
     +running the same push command against each member remote individually.
     +
    -+The behaviour upon failure depends on the kind of error encountered:
    -+
    -+If a member remote rejects the push, for example due to a
    -+non-fast-forward update, force needed but not given, an existing tag,
    -+or a server-side hook refusing a ref, Git reports the error and continues
    -+pushing to the remaining remotes in the group. The overall exit code is
    -+non-zero if any member push fails.
    -+
    -+If a member remote cannot be contacted at all, for example because the
    -+repository does not exist, authentication fails, or the network is
    -+unreachable, the push stops at that point and the remaining remotes
    -+are not attempted.
    ++When pushing to a group of more than one remote, Git spawns a separate
    ++`git push` subprocess for each member remote in sequence.  Each subprocess
    ++receives the same flags and refspecs as the original invocation.  This
    ++means that per-remote push mappings configured via `remote.<name>.push`
    ++and mirror mode (`remote.<name>.mirror`) are evaluated independently for
    ++each remote, and a mirror remote in the group cannot affect the push
    ++behaviour of other non-mirror remotes in the same group.
    ++
    ++The `--atomic` option is not supported for group pushes, because atomicity
    ++can only be guaranteed within a single transport connection to a single
    ++remote.  Git will refuse the invocation with an error if `--atomic` is
    ++combined with a group name.
    ++
    ++If any member remote fails whether due to a push rejection (e.g. a
    ++non-fast-forward update, a server-side hook refusing a ref) or a connection
    ++error (e.g. the repository does not exist, authentication fails, or the
    ++network is unreachable), Git reports the error and continues pushing to
    ++the remaining remotes in the group.  The overall exit code is non-zero if
    ++any member push fails.
     +
     +This means the user is responsible for ensuring that the sequence of
     +individual pushes makes sense. If `git push r1`` would fail for a given
    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`
      
     
      ## builtin/push.c ##
    +@@
    + #include "config.h"
    + #include "environment.h"
    + #include "gettext.h"
    ++#include "hex.h"
    + #include "refspec.h"
    + #include "run-command.h"
    + #include "remote.h"
    +@@ builtin/push.c: static int git_push_config(const char *k, const char *v,
    + 	return git_default_config(k, v, ctx, NULL);
    + }
    + 
    ++static int push_multiple(struct string_list *list,
    ++			 const struct string_list *push_options,
    ++			 int flags,
    ++			 int tags,
    ++			 const char **refspecs,
    ++			 int refspec_nr)
    ++{
    ++	int i, result = 0;
    ++	struct strvec argv = STRVEC_INIT;
    ++
    ++	strvec_push(&argv, "push");
    ++
    ++	if (flags & TRANSPORT_PUSH_FORCE)
    ++		strvec_push(&argv, "--force");
    ++	if (flags & TRANSPORT_PUSH_DRY_RUN)
    ++		strvec_push(&argv, "--dry-run");
    ++	if (flags & TRANSPORT_PUSH_PORCELAIN)
    ++		strvec_push(&argv, "--porcelain");
    ++	if (flags & TRANSPORT_PUSH_PRUNE)
    ++		strvec_push(&argv, "--prune");
    ++	if (flags & TRANSPORT_PUSH_NO_HOOK)
    ++		strvec_push(&argv, "--no-verify");
    ++	if (flags & TRANSPORT_PUSH_FOLLOW_TAGS)
    ++		strvec_push(&argv, "--follow-tags");
    ++	if (flags & TRANSPORT_PUSH_SET_UPSTREAM)
    ++		strvec_push(&argv, "--set-upstream");
    ++	if (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)
    ++		strvec_push(&argv, "--force-if-includes");
    ++	if (flags & TRANSPORT_PUSH_ALL)
    ++		strvec_push(&argv, "--all");
    ++	if (flags & TRANSPORT_PUSH_MIRROR)
    ++		strvec_push(&argv, "--mirror");
    ++
    ++	if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
    ++		strvec_push(&argv, "--signed=yes");
    ++	else if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)
    ++		strvec_push(&argv, "--signed=if-asked");
    ++	if (!thin)
    ++		strvec_push(&argv, "--no-thin");
    ++
    ++	if (deleterefs)
    ++		strvec_push(&argv, "--delete");
    ++
    ++	if (receivepack)
    ++		strvec_pushf(&argv, "--receive-pack=%s", receivepack);
    ++	if (verbosity >= 2)
    ++		strvec_push(&argv, "-v");
    ++	if (verbosity >= 1)
    ++		strvec_push(&argv, "-v");
    ++	else if (verbosity < 0)
    ++		strvec_push(&argv, "-q");
    ++	if (progress > 0)
    ++		strvec_push(&argv, "--progress");
    ++	else if (progress == 0)
    ++		strvec_push(&argv, "--no-progress");
    ++
    ++	if (family == TRANSPORT_FAMILY_IPV4)
    ++		strvec_push(&argv, "--ipv4");
    ++	else if (family == TRANSPORT_FAMILY_IPV6)
    ++		strvec_push(&argv, "--ipv6");
    ++
    ++	if (recurse_submodules == RECURSE_SUBMODULES_CHECK)
    ++		strvec_push(&argv, "--recurse-submodules=check");
    ++	else if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
    ++		strvec_push(&argv, "--recurse-submodules=on-demand");
    ++	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
    ++		strvec_push(&argv, "--recurse-submodules=only");
    ++	else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
    ++		strvec_push(&argv, "--recurse-submodules=no");
    ++
    ++
    ++	if (tags)
    ++		strvec_push(&argv, "--tags");
    ++
    ++	for (i = 0; i < push_options->nr; i++)
    ++		strvec_pushf(&argv, "--push-option=%s",
    ++			     push_options->items[i].string);
    ++
    ++	for (i = 0; i < cas.nr; i++) {
    ++		if (cas.entry[i].use_tracking) {
    ++			strvec_pushf(&argv, "--force-with-lease=%s",
    ++				     cas.entry[i].refname);
    ++		} else if (!is_null_oid(&cas.entry[i].expect)) {
    ++			strvec_pushf(&argv, "--force-with-lease=%s:%s",
    ++				     cas.entry[i].refname,
    ++				     oid_to_hex(&cas.entry[i].expect));
    ++		} else {
    ++			strvec_push(&argv, "--force-with-lease");
    ++		}
    ++	}
    ++
    ++	for (i = 0; i < list->nr; i++) {
    ++		const char *name = list->items[i].string;
    ++		struct child_process cmd = CHILD_PROCESS_INIT;
    ++		int j;
    ++
    ++		strvec_pushv(&cmd.args, argv.v);
    ++		strvec_push(&cmd.args, name);
    ++
    ++		for (j = 0; j < refspec_nr; j++)
    ++			strvec_push(&cmd.args, refspecs[j]);
    ++
    ++		if (verbosity >= 0)
    ++			printf(_("Pushing to %s\n"), name);
    ++
    ++		cmd.git_cmd = 1;
    ++		if (run_command(&cmd)) {
    ++			error(_("could not push to %s"), name);
    ++			result = 1;
    ++		}
    ++	}
    ++
    ++	strvec_clear(&argv);
    ++	return result;
    ++}
    ++
    + int cmd_push(int argc,
    + 	     const char **argv,
    + 	     const char *prefix,
     @@ builtin/push.c: int cmd_push(int argc,
      	int flags = 0;
      	int tags = 0;
    @@ builtin/push.c: int cmd_push(int argc,
      			die(_("push options must not have new line characters"));
      
     -	rc = do_push(flags, push_options, remote);
    -+	/*
    -+	 * Push to each remote in remote_group. For a plain "git push <remote>"
    -+	 * or a default push, remote_group has exactly one entry and the loop
    -+	 * runs once — there is nothing structurally special about that case.
    -+	 * For a group, the loop runs once per member remote.
    -+	 *
    -+	 * Mirror detection and the --mirror/--all + refspec conflict checks
    -+	 * are done per remote inside the loop. A remote configured with
    -+	 * remote.NAME.mirror=true implies mirror mode for that remote only —
    -+	 * other non-mirror remotes in the same group are unaffected.
    -+	 *
    -+	 * rs is rebuilt from scratch for each remote so that per-remote push
    -+	 * mappings (remote.NAME.push config) are resolved against the correct
    -+	 * remote. iter_flags is derived from a clean snapshot of flags taken
    -+	 * before the loop so that a mirror remote cannot bleed
    -+	 * TRANSPORT_PUSH_FORCE into subsequent non-mirror remotes in the
    -+	 * same group.
    -+	 */
    -+	base_flags = flags;
    -+	for (size_t i = 0; i < remote_group.nr; i++) {
    -+		int iter_flags = base_flags;
    -+		struct remote *r = pushremote_get(remote_group.items[i].string);
    -+		if (!r)
    -+			die(_("no such remote or remote group: %s"),
    -+			    remote_group.items[i].string);
    -+
    -+		if (r->mirror)
    -+			iter_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
    -+
    -+		if (iter_flags & TRANSPORT_PUSH_ALL) {
    -+			if (argc >= 2)
    -+				die(_("--all can't be combined with refspecs"));
    -+		}
    -+		if (iter_flags & TRANSPORT_PUSH_MIRROR) {
    -+			if (argc >= 2)
    -+				die(_("--mirror can't be combined with refspecs"));
    ++	if (remote_group.nr == 1) {
    ++		/*
    ++		 * Single remote (the common case): run do_push() directly
    ++		 * in this process.  The loop runs exactly once.
    ++		 *
    ++		 * Mirror detection and the --mirror/--all + refspec conflict
    ++		 * checks are done here.  rs is rebuilt so that per-remote push
    ++		 * mappings (remote.NAME.push config) are resolved against the
    ++		 * correct remote.  inner_flags is a snapshot of flags so that a
    ++		 * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any
    ++		 * subsequent call.
    ++		 */
    ++		base_flags = flags;
    ++		{
    ++			int inner_flags = base_flags;
    ++			struct remote *r = pushremote_get(remote_group.items[0].string);
    ++			if (!r)
    ++				die(_("no such remote or remote group: %s"),
    ++				    remote_group.items[0].string);
    ++
    ++			if (r->mirror)
    ++				inner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
    ++
    ++			if (inner_flags & TRANSPORT_PUSH_ALL) {
    ++				if (argc >= 2)
    ++					die(_("--all can't be combined with refspecs"));
    ++			}
    ++			if (inner_flags & TRANSPORT_PUSH_MIRROR) {
    ++				if (argc >= 2)
    ++					die(_("--mirror can't be combined with refspecs"));
    ++			}
    ++
    ++			refspec_clear(&rs);
    ++			rs = (struct refspec) REFSPEC_INIT_PUSH;
    ++
    ++			if (tags)
    ++				refspec_append(&rs, "refs/tags/*");
    ++			if (argc > 0)
    ++				set_refspecs(argv + 1, argc - 1, r);
    ++
    ++			rc = do_push(inner_flags, push_options, r);
     +		}
    -+
    -+		refspec_clear(&rs);
    -+		rs = (struct refspec) REFSPEC_INIT_PUSH;
    -+
    -+		if (tags)
    -+			refspec_append(&rs, "refs/tags/*");
    -+		if (argc > 0)
    -+			set_refspecs(argv + 1, argc - 1, r);
    -+
    -+		rc |= do_push(iter_flags, push_options, r);
    ++	} else {
    ++		/*
    ++		 * Multiple remotes: spawn one "git push <remote> [<refspecs>]"
    ++		 * subprocess per remote, sequentially.
    ++		 *
    ++		 * Options that only make sense for a single transport connection
    ++		 * are rejected here.
    ++		 */
    ++		if (flags & TRANSPORT_PUSH_ATOMIC)
    ++			die(_("--atomic can only be used when pushing to one remote"));
    ++
    ++		rc = push_multiple(&remote_group, push_options, flags,
    ++				   tags,
    ++				   argc > 1 ? argv + 1 : NULL,
    ++				   argc > 1 ? argc - 1 : 0);
     +	}
     +
      	string_list_clear(&push_options_cmdline, 0);
    @@ t/t5566-push-group.sh (new)
     +
     +test_description='push to remote group'
     +
    ++GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
    ++export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
    ++
     +. ./test-lib.sh
     +
     +test_expect_success 'setup' '
     +	for i in 1 2 3
     +	do
    -+		git init --bare dest-$i.git || return 1
    ++		git init --bare dest-$i.git &&
    ++		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
    ++		return 1
     +	done &&
     +	test_tick &&
     +	git commit --allow-empty -m "initial" &&
    @@ t/t5566-push-group.sh (new)
     +	test_grep "mirror" err &&
     +	git config unset remote.remote-1.mirror
     +'
    ++
     +test_expect_success 'push.default=current works with group push' '
     +	git config set push.default current &&
     +	test_tick &&
    @@ t/t5566-push-group.sh (new)
     +	git config unset push.default
     +'
     +
    ++test_expect_success '--atomic is rejected for group push' '
    ++	test_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&
    ++	test_grep "atomic" err
    ++'
    ++
     +test_expect_success 'push continues past rejection to remaining remotes' '
     +	for i in c1 c2 c3
     +	do
    @@ t/t5566-push-group.sh (new)
     +	# initial sync
     +	git push continue-group HEAD:refs/heads/main &&
     +
    -+  # advance c2 independently
    -+  git clone dest-c2.git tmp-c2 &&
    -+  (
    -+    cd tmp-c2 &&
    -+    git checkout -b main origin/main &&
    -+    test_commit c2_independent &&
    -+    git push origin HEAD:refs/heads/main
    -+  ) &&
    -+  rm -rf tmp-c2 &&
    ++	# advance c2 independently
    ++	git clone dest-c2.git tmp-c2 &&
    ++	(
    ++		cd tmp-c2 &&
    ++		git checkout -b main origin/main &&
    ++		test_commit c2_independent &&
    ++		git push origin HEAD:refs/heads/main
    ++	) &&
    ++	rm -rf tmp-c2 &&
     +
     +	test_tick &&
     +	git commit --allow-empty -m "local diverging commit" &&
    @@ t/t5566-push-group.sh (new)
     +	! test_cmp expect actual-c2
     +'
     +
    -+test_expect_success 'fatal connection error stops remaining remotes' '
    ++test_expect_success 'fatal connection error does not stop remaining remotes' '
     +	for i in f1 f2 f3
     +	do
     +		git init --bare dest-$i.git || return 1
    @@ t/t5566-push-group.sh (new)
     +	test_tick &&
     +	git commit --allow-empty -m "after fatal setup" &&
     +
    ++	# overall exit code is non-zero because f2 failed
     +	test_must_fail git push fatal-group HEAD:refs/heads/main &&
     +
     +	git rev-parse HEAD >expect &&
    ++
    ++	# f1 and f3 should both have the new commit — subprocesses are independent
     +	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
     +	test_cmp expect actual-f1 &&
    -+
    -+	# f3 should not be updated
     +	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
    -+	! test_cmp expect actual-f3 &&
    ++	test_cmp expect actual-f3 &&
     +
     +	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
     +'
Usman Akinyemi (2):
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/fetch.c             |  42 ------
 builtin/push.c              | 250 +++++++++++++++++++++++++++++++-----
 remote.c                    |  37 ++++++
 remote.h                    |  12 ++
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 7 files changed, 499 insertions(+), 83 deletions(-)
 create mode 100755 t/t5566-push-group.sh
-- 
2.53.0
Usman AkinyemiApr 27, 2026, 14:05 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v4 1/2] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..cfb26eb284 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index a664cd166a..7133d29332 100644
--- a/remote.c
+++ b/remote.c
@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index fc052945ee..8ff2bd88fa 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+                    const struct config_context *ctx,
+                    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.53.0
Usman AkinyemiApr 27, 2026, 14:05 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v4 2/2] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote, the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/push.c              | 250 +++++++++++++++++++++++++++++++-----
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 4 files changed, 450 insertions(+), 41 deletions(-)
 create mode 100755 t/t5566-push-group.sh
Show changes to 4 files +450 −41

Documentation/git-push.adoc, builtin/push.c, t/meson.build, t/t5566-push-group.sh

diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
index e5ba3a6742..aa221c3909 100644
--- a/Documentation/git-push.adoc
+++ b/Documentation/git-push.adoc
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
 
 DESCRIPTION
 -----------
-
-Updates one or more branches, tags, or other references in a remote
-repository from your local repository, and sends all necessary data
-that isn't already on the remote.
+Updates one or more branches, tags, or other references in one or more
+remote repositories from your local repository, and sends all necessary
+data that isn't already on the remote.
 
 The simplest way to push is `git push <remote> <branch>`.
 `git push origin main` will push the local `main` branch to the `main`
 branch on the remote named `origin`.
 
-The `<repository>` argument defaults to the upstream for the current branch,
-or `origin` if there's no configured upstream.
+You can also push to multiple remotes at once by using a remote group.
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "origin gitlab backup"
+
+Then `git push all-remotes` will push to `origin`, `gitlab`, and
+`backup` in turn, as if you had run `git push` against each one
+individually.  Each remote is pushed independently using its own
+push mapping configuration. There is a `remotes.<group>` entry in
+the configuration file. (See linkgit:git-config[1]).
+
+The `<repository>` argument defaults to the upstream for the current
+branch, or `origin` if there's no configured upstream.
 
 To decide which branches, tags, or other refs to push, Git uses
 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
 _<repository>_::
 	The "remote" repository that is the destination of a push
 	operation.  This parameter can be either a URL
-	(see the section <<URLS,GIT URLS>> below) or the name
-	of a remote (see the section <<REMOTES,REMOTES>> below).
+	(see the section <<URLS,GIT URLS>> below), the name
+	of a remote (see the section <<REMOTES,REMOTES>> below),
+	or the name of a remote group
+	(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
 
 `<refspec>...`::
 	Specify what destination ref to update with what source object.
@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
 
 include::urls-remotes.adoc[]
 
+[[REMOTE-GROUPS]]
+REMOTE GROUPS
+-------------
+
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "r1 r2 r3"
+
+When a group name is given as the `<repository>` argument, the push is
+performed to each member remote in turn.  The defining principle is:
+
+	git push <options> all-remotes <args>
+
+is exactly equivalent to:
+
+	git push <options> r1 <args>
+	git push <options> r2 <args>
+	...
+	git push <options> rN <args>
+
+where r1, r2, ..., rN are the members of `all-remotes`.  No special
+behaviour is added or removed — the group is purely a shorthand for
+running the same push command against each member remote individually.
+
+When pushing to a group of more than one remote, Git spawns a separate
+`git push` subprocess for each member remote in sequence.  Each subprocess
+receives the same flags and refspecs as the original invocation.  This
+means that per-remote push mappings configured via `remote.<name>.push`
+and mirror mode (`remote.<name>.mirror`) are evaluated independently for
+each remote, and a mirror remote in the group cannot affect the push
+behaviour of other non-mirror remotes in the same group.
+
+The `--atomic` option is not supported for group pushes, because atomicity
+can only be guaranteed within a single transport connection to a single
+remote.  Git will refuse the invocation with an error if `--atomic` is
+combined with a group name.
+
+If any member remote fails whether due to a push rejection (e.g. a
+non-fast-forward update, a server-side hook refusing a ref) or a connection
+error (e.g. the repository does not exist, authentication fails, or the
+network is unreachable), Git reports the error and continues pushing to
+the remaining remotes in the group.  The overall exit code is non-zero if
+any member push fails.
+
+This means the user is responsible for ensuring that the sequence of
+individual pushes makes sense. If `git push r1`` would fail for a given
+set of options and arguments, then `git push all-remotes` will fail in
+the same way when it reaches r1. The group push does not do anything
+special to make a failing individual push succeed.
+
 OUTPUT
 ------
 
diff --git a/builtin/push.c b/builtin/push.c
index 7100ffba5d..10384f265c 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -10,6 +10,7 @@
 #include "config.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "refspec.h"
 #include "run-command.h"
 #include "remote.h"
@@ -544,6 +545,122 @@ static int git_push_config(const char *k, const char *v,
 	return git_default_config(k, v, ctx, NULL);
 }
 
+static int push_multiple(struct string_list *list,
+			 const struct string_list *push_options,
+			 int flags,
+			 int tags,
+			 const char **refspecs,
+			 int refspec_nr)
+{
+	int i, result = 0;
+	struct strvec argv = STRVEC_INIT;
+
+	strvec_push(&argv, "push");
+
+	if (flags & TRANSPORT_PUSH_FORCE)
+		strvec_push(&argv, "--force");
+	if (flags & TRANSPORT_PUSH_DRY_RUN)
+		strvec_push(&argv, "--dry-run");
+	if (flags & TRANSPORT_PUSH_PORCELAIN)
+		strvec_push(&argv, "--porcelain");
+	if (flags & TRANSPORT_PUSH_PRUNE)
+		strvec_push(&argv, "--prune");
+	if (flags & TRANSPORT_PUSH_NO_HOOK)
+		strvec_push(&argv, "--no-verify");
+	if (flags & TRANSPORT_PUSH_FOLLOW_TAGS)
+		strvec_push(&argv, "--follow-tags");
+	if (flags & TRANSPORT_PUSH_SET_UPSTREAM)
+		strvec_push(&argv, "--set-upstream");
+	if (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)
+		strvec_push(&argv, "--force-if-includes");
+	if (flags & TRANSPORT_PUSH_ALL)
+		strvec_push(&argv, "--all");
+	if (flags & TRANSPORT_PUSH_MIRROR)
+		strvec_push(&argv, "--mirror");
+
+	if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
+		strvec_push(&argv, "--signed=yes");
+	else if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)
+		strvec_push(&argv, "--signed=if-asked");
+	if (!thin)
+		strvec_push(&argv, "--no-thin");
+
+	if (deleterefs)
+		strvec_push(&argv, "--delete");
+
+	if (receivepack)
+		strvec_pushf(&argv, "--receive-pack=%s", receivepack);
+	if (verbosity >= 2)
+		strvec_push(&argv, "-v");
+	if (verbosity >= 1)
+		strvec_push(&argv, "-v");
+	else if (verbosity < 0)
+		strvec_push(&argv, "-q");
+	if (progress > 0)
+		strvec_push(&argv, "--progress");
+	else if (progress == 0)
+		strvec_push(&argv, "--no-progress");
+
+	if (family == TRANSPORT_FAMILY_IPV4)
+		strvec_push(&argv, "--ipv4");
+	else if (family == TRANSPORT_FAMILY_IPV6)
+		strvec_push(&argv, "--ipv6");
+
+	if (recurse_submodules == RECURSE_SUBMODULES_CHECK)
+		strvec_push(&argv, "--recurse-submodules=check");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
+		strvec_push(&argv, "--recurse-submodules=on-demand");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
+		strvec_push(&argv, "--recurse-submodules=only");
+	else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
+		strvec_push(&argv, "--recurse-submodules=no");
+
+
+	if (tags)
+		strvec_push(&argv, "--tags");
+
+	for (i = 0; i < push_options->nr; i++)
+		strvec_pushf(&argv, "--push-option=%s",
+			     push_options->items[i].string);
+
+	for (i = 0; i < cas.nr; i++) {
+		if (cas.entry[i].use_tracking) {
+			strvec_pushf(&argv, "--force-with-lease=%s",
+				     cas.entry[i].refname);
+		} else if (!is_null_oid(&cas.entry[i].expect)) {
+			strvec_pushf(&argv, "--force-with-lease=%s:%s",
+				     cas.entry[i].refname,
+				     oid_to_hex(&cas.entry[i].expect));
+		} else {
+			strvec_push(&argv, "--force-with-lease");
+		}
+	}
+
+	for (i = 0; i < list->nr; i++) {
+		const char *name = list->items[i].string;
+		struct child_process cmd = CHILD_PROCESS_INIT;
+		int j;
+
+		strvec_pushv(&cmd.args, argv.v);
+		strvec_push(&cmd.args, name);
+
+		for (j = 0; j < refspec_nr; j++)
+			strvec_push(&cmd.args, refspecs[j]);
+
+		if (verbosity >= 0)
+			printf(_("Pushing to %s\n"), name);
+
+		cmd.git_cmd = 1;
+		if (run_command(&cmd)) {
+			error(_("could not push to %s"), name);
+			result = 1;
+		}
+	}
+
+	strvec_clear(&argv);
+	return result;
+}
+
 int cmd_push(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -552,12 +669,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
+	int base_flags;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP;
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -620,39 +738,45 @@ int cmd_push(int argc,
 	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
 		flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
 
-	if (tags)
-		refspec_append(&rs, "refs/tags/*");
-
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
-	}
-
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
-
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
-
-	if (flags & TRANSPORT_PUSH_ALL) {
-		if (argc >= 2)
-			die(_("--all can't be combined with refspecs"));
-	}
-	if (flags & TRANSPORT_PUSH_MIRROR) {
-		if (argc >= 2)
-			die(_("--mirror can't be combined with refspecs"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group)) {
+			/*
+			 * Not a configured remote name or group name.
+			 * Try treating it as a direct URL or path, e.g.
+			 *   git push /tmp/foo.git
+			 *   git push https://github.com/user/repo.git
+			 * pushremote_get() creates an anonymous remote
+			 * from the URL so the loop below can handle it
+			 * identically to a named remote.
+			 */
+			struct remote *r = pushremote_get(repo);
+			if (!r)
+				die(_("bad repository '%s'"), repo);
+			string_list_append(&remote_group, r->name);
+		}
+	} else {
+		struct remote *r = pushremote_get(NULL);
+		if (!r)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"
+			    "\n"
+			    "To push to multiple remotes at once, configure a remote group using\n"
+			    "\n"
+			    "    git config remotes.<groupname> \"<remote1> <remote2>\"\n"
+			    "\n"
+			    "and then push using the group name\n"
+			    "\n"
+			    "    git push <groupname>\n"));
+		string_list_append(&remote_group, r->name);
 	}
 
 	if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -662,10 +786,70 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	if (remote_group.nr == 1) {
+		/*
+		 * Single remote (the common case): run do_push() directly
+		 * in this process.  The loop runs exactly once.
+		 *
+		 * Mirror detection and the --mirror/--all + refspec conflict
+		 * checks are done here.  rs is rebuilt so that per-remote push
+		 * mappings (remote.NAME.push config) are resolved against the
+		 * correct remote.  inner_flags is a snapshot of flags so that a
+		 * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any
+		 * subsequent call.
+		 */
+		base_flags = flags;
+		{
+			int inner_flags = base_flags;
+			struct remote *r = pushremote_get(remote_group.items[0].string);
+			if (!r)
+				die(_("no such remote or remote group: %s"),
+				    remote_group.items[0].string);
+
+			if (r->mirror)
+				inner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+			if (inner_flags & TRANSPORT_PUSH_ALL) {
+				if (argc >= 2)
+					die(_("--all can't be combined with refspecs"));
+			}
+			if (inner_flags & TRANSPORT_PUSH_MIRROR) {
+				if (argc >= 2)
+					die(_("--mirror can't be combined with refspecs"));
+			}
+
+			refspec_clear(&rs);
+			rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+			if (tags)
+				refspec_append(&rs, "refs/tags/*");
+			if (argc > 0)
+				set_refspecs(argv + 1, argc - 1, r);
+
+			rc = do_push(inner_flags, push_options, r);
+		}
+	} else {
+		/*
+		 * Multiple remotes: spawn one "git push <remote> [<refspecs>]"
+		 * subprocess per remote, sequentially.
+		 *
+		 * Options that only make sense for a single transport connection
+		 * are rejected here.
+		 */
+		if (flags & TRANSPORT_PUSH_ATOMIC)
+			die(_("--atomic can only be used when pushing to one remote"));
+
+		rc = push_multiple(&remote_group, push_options, flags,
+				   tags,
+				   argc > 1 ? argv + 1 : NULL,
+				   argc > 1 ? argc - 1 : 0);
+	}
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..bd090627e9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -704,6 +704,7 @@ integration_tests = [
   't5563-simple-http-auth.sh',
   't5564-http-proxy.sh',
   't5565-push-multiple.sh',
+  't5566-push-group.sh',
   't5570-git-daemon.sh',
   't5571-pre-push-hook.sh',
   't5572-pull-submodule.sh',
diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
new file mode 100755
index 0000000000..a7d59352b1
--- /dev/null
+++ b/t/t5566-push-group.sh
@@ -0,0 +1,160 @@
+#!/bin/sh
+
+test_description='push to remote group'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	for i in 1 2 3
+	do
+		git init --bare dest-$i.git &&
+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
+		return 1
+	done &&
+	test_tick &&
+	git commit --allow-empty -m "initial" &&
+	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
+	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
+	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
+	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
+	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
+	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
+	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
+'
+
+test_expect_success 'push to remote group updates all members correctly' '
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push second commit to group updates all members' '
+	test_tick &&
+	git commit --allow-empty -m "second" &&
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push to single remote in group does not affect others' '
+	test_tick &&
+	git commit --allow-empty -m "third" &&
+	git push remote-1 HEAD:refs/heads/main &&
+	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
+	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
+	! test_cmp hash-after-1 hash-after-2
+'
+
+test_expect_success 'mirror remote in group with refspec fails' '
+	git config set remote.remote-1.mirror true &&
+	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "mirror" err &&
+	git config unset remote.remote-1.mirror
+'
+
+test_expect_success 'push.default=current works with group push' '
+	git config set push.default current &&
+	test_tick &&
+	git commit --allow-empty -m "fifth" &&
+	git push all-remotes &&
+	git config unset push.default
+'
+
+test_expect_success '--atomic is rejected for group push' '
+	test_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "atomic" err
+'
+
+test_expect_success 'push continues past rejection to remaining remotes' '
+	for i in c1 c2 c3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
+	git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
+	git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
+	git config set remotes.continue-group "c1 c2 c3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for continue test" &&
+
+	# initial sync
+	git push continue-group HEAD:refs/heads/main &&
+
+	# advance c2 independently
+	git clone dest-c2.git tmp-c2 &&
+	(
+		cd tmp-c2 &&
+		git checkout -b main origin/main &&
+		test_commit c2_independent &&
+		git push origin HEAD:refs/heads/main
+	) &&
+	rm -rf tmp-c2 &&
+
+	test_tick &&
+	git commit --allow-empty -m "local diverging commit" &&
+
+	# push: c2 rejects, others succeed
+	test_must_fail git push continue-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+	git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
+	git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
+	test_cmp expect actual-c1 &&
+	test_cmp expect actual-c3 &&
+
+	# c2 should not have the new commit
+	git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
+	! test_cmp expect actual-c2
+'
+
+test_expect_success 'fatal connection error does not stop remaining remotes' '
+	for i in f1 f2 f3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
+	git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
+	git config set remotes.fatal-group "f1 f2 f3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for fatal test" &&
+
+	# initial sync
+	git push fatal-group HEAD:refs/heads/main &&
+
+	# break f2
+	git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
+
+	test_tick &&
+	git commit --allow-empty -m "after fatal setup" &&
+
+	# overall exit code is non-zero because f2 failed
+	test_must_fail git push fatal-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+
+	# f1 and f3 should both have the new commit — subprocesses are independent
+	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
+	test_cmp expect actual-f1 &&
+	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
+	test_cmp expect actual-f3 &&
+
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
+'
+
+test_done
-- 
2.53.0
Junio C HamanoApr 28, 2026, 01:47 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v3 0/2] push: add support for pushing to remote groups

Something like this is needed to workaround -Werror=sign-compare complaints.
Show changes to 2 files +4 −3

builtin/push.c, remote.h

diff --git a/builtin/push.c b/builtin/push.c
index 10384f265c..6021b71d66 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -552,7 +552,8 @@ static int push_multiple(struct string_list *list,
 			 const char **refspecs,
 			 int refspec_nr)
 {
-	int i, result = 0;
+	int result = 0;
+	size_t i;
 	struct strvec argv = STRVEC_INIT;
 
 	strvec_push(&argv, "push");
diff --git a/remote.h b/remote.h
index 8ff2bd88fa..7915be3111 100644
--- a/remote.h
+++ b/remote.h
@@ -430,8 +430,8 @@ struct push_cas_option {
 		unsigned use_tracking:1;
 		char *refname;
 	} *entry;
-	int nr;
-	int alloc;
+	size_t nr;
+	size_t alloc;
 };
 
 int parseopt_push_cas_option(const struct option *, const char *arg, int unset);
Usman AkinyemiMay 3, 2026, 15:33 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v5 0/3] push: add support for pushing to remote groups

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into two patches:
  - Patch 1 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
Changes in v5:
- fixed the -Werror=sign-compare in builtin/push.c complaints and also added
  a new commit that fix the remote.h
Range-diff v4 -> v5:
-:  ---------- > 1:  e01126890c remote: fix sign-compare warnings in push_cas_option
1:  20ed79546f = 2:  adbce652e6 remote: move remote group resolution to remote.c
2:  964694e587 ! 3:  a8d5f4b7bd push: support pushing to a remote group
    @@ builtin/push.c: static int git_push_config(const char *k, const char *v,
     +			 const char **refspecs,
     +			 int refspec_nr)
     +{
    -+	int i, result = 0;
    ++	int result = 0;
    ++	size_t i;
     +	struct strvec argv = STRVEC_INIT;
     +
     +	strvec_push(&argv, "push");
Usman Akinyemi (3):
  remote: fix sign-compare warnings in push_cas_option
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/fetch.c             |  42 ------
 builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----
 remote.c                    |  37 ++++++
 remote.h                    |  16 ++-
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 7 files changed, 502 insertions(+), 85 deletions(-)
 create mode 100755 t/t5566-push-group.sh
-- 
2.53.0
Usman AkinyemiMay 3, 2026, 15:34 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v5 1/3] remote: fix sign-compare warnings in push_cas_option

Replace `int` with `size_t` for `nr` and `alloc` in `struct push_cas_option` to avoid -Werror=sign-compare warnings when comparing against size-based values.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 remote.h | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
Show changes to remote.h +2 −2
diff --git a/remote.h b/remote.h
index fc052945ee..741d14a9fc 100644
--- a/remote.h
+++ b/remote.h
@@ -418,8 +418,8 @@ struct push_cas_option {
 		unsigned use_tracking:1;
 		char *refname;
 	} *entry;
-	int nr;
-	int alloc;
+	size_t nr;
+	size_t alloc;
 };
 
 int parseopt_push_cas_option(const struct option *, const char *arg, int unset);
-- 
2.53.0
Usman AkinyemiMay 3, 2026, 15:34 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v5 2/3] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..cfb26eb284 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index a664cd166a..7133d29332 100644
--- a/remote.c
+++ b/remote.c
@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index 741d14a9fc..7915be3111 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+                    const struct config_context *ctx,
+                    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.53.0
Usman AkinyemiMay 3, 2026, 15:34 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v5 3/3] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote, the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 4 files changed, 451 insertions(+), 41 deletions(-)
 create mode 100755 t/t5566-push-group.sh
Show changes to 4 files +451 −41

Documentation/git-push.adoc, builtin/push.c, t/meson.build, t/t5566-push-group.sh

diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
index e5ba3a6742..aa221c3909 100644
--- a/Documentation/git-push.adoc
+++ b/Documentation/git-push.adoc
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
 
 DESCRIPTION
 -----------
-
-Updates one or more branches, tags, or other references in a remote
-repository from your local repository, and sends all necessary data
-that isn't already on the remote.
+Updates one or more branches, tags, or other references in one or more
+remote repositories from your local repository, and sends all necessary
+data that isn't already on the remote.
 
 The simplest way to push is `git push <remote> <branch>`.
 `git push origin main` will push the local `main` branch to the `main`
 branch on the remote named `origin`.
 
-The `<repository>` argument defaults to the upstream for the current branch,
-or `origin` if there's no configured upstream.
+You can also push to multiple remotes at once by using a remote group.
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "origin gitlab backup"
+
+Then `git push all-remotes` will push to `origin`, `gitlab`, and
+`backup` in turn, as if you had run `git push` against each one
+individually.  Each remote is pushed independently using its own
+push mapping configuration. There is a `remotes.<group>` entry in
+the configuration file. (See linkgit:git-config[1]).
+
+The `<repository>` argument defaults to the upstream for the current
+branch, or `origin` if there's no configured upstream.
 
 To decide which branches, tags, or other refs to push, Git uses
 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
 _<repository>_::
 	The "remote" repository that is the destination of a push
 	operation.  This parameter can be either a URL
-	(see the section <<URLS,GIT URLS>> below) or the name
-	of a remote (see the section <<REMOTES,REMOTES>> below).
+	(see the section <<URLS,GIT URLS>> below), the name
+	of a remote (see the section <<REMOTES,REMOTES>> below),
+	or the name of a remote group
+	(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
 
 `<refspec>...`::
 	Specify what destination ref to update with what source object.
@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
 
 include::urls-remotes.adoc[]
 
+[[REMOTE-GROUPS]]
+REMOTE GROUPS
+-------------
+
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "r1 r2 r3"
+
+When a group name is given as the `<repository>` argument, the push is
+performed to each member remote in turn.  The defining principle is:
+
+	git push <options> all-remotes <args>
+
+is exactly equivalent to:
+
+	git push <options> r1 <args>
+	git push <options> r2 <args>
+	...
+	git push <options> rN <args>
+
+where r1, r2, ..., rN are the members of `all-remotes`.  No special
+behaviour is added or removed — the group is purely a shorthand for
+running the same push command against each member remote individually.
+
+When pushing to a group of more than one remote, Git spawns a separate
+`git push` subprocess for each member remote in sequence.  Each subprocess
+receives the same flags and refspecs as the original invocation.  This
+means that per-remote push mappings configured via `remote.<name>.push`
+and mirror mode (`remote.<name>.mirror`) are evaluated independently for
+each remote, and a mirror remote in the group cannot affect the push
+behaviour of other non-mirror remotes in the same group.
+
+The `--atomic` option is not supported for group pushes, because atomicity
+can only be guaranteed within a single transport connection to a single
+remote.  Git will refuse the invocation with an error if `--atomic` is
+combined with a group name.
+
+If any member remote fails whether due to a push rejection (e.g. a
+non-fast-forward update, a server-side hook refusing a ref) or a connection
+error (e.g. the repository does not exist, authentication fails, or the
+network is unreachable), Git reports the error and continues pushing to
+the remaining remotes in the group.  The overall exit code is non-zero if
+any member push fails.
+
+This means the user is responsible for ensuring that the sequence of
+individual pushes makes sense. If `git push r1`` would fail for a given
+set of options and arguments, then `git push all-remotes` will fail in
+the same way when it reaches r1. The group push does not do anything
+special to make a failing individual push succeed.
+
 OUTPUT
 ------
 
diff --git a/builtin/push.c b/builtin/push.c
index 7100ffba5d..6021b71d66 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -10,6 +10,7 @@
 #include "config.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "refspec.h"
 #include "run-command.h"
 #include "remote.h"
@@ -544,6 +545,123 @@ static int git_push_config(const char *k, const char *v,
 	return git_default_config(k, v, ctx, NULL);
 }
 
+static int push_multiple(struct string_list *list,
+			 const struct string_list *push_options,
+			 int flags,
+			 int tags,
+			 const char **refspecs,
+			 int refspec_nr)
+{
+	int result = 0;
+	size_t i;
+	struct strvec argv = STRVEC_INIT;
+
+	strvec_push(&argv, "push");
+
+	if (flags & TRANSPORT_PUSH_FORCE)
+		strvec_push(&argv, "--force");
+	if (flags & TRANSPORT_PUSH_DRY_RUN)
+		strvec_push(&argv, "--dry-run");
+	if (flags & TRANSPORT_PUSH_PORCELAIN)
+		strvec_push(&argv, "--porcelain");
+	if (flags & TRANSPORT_PUSH_PRUNE)
+		strvec_push(&argv, "--prune");
+	if (flags & TRANSPORT_PUSH_NO_HOOK)
+		strvec_push(&argv, "--no-verify");
+	if (flags & TRANSPORT_PUSH_FOLLOW_TAGS)
+		strvec_push(&argv, "--follow-tags");
+	if (flags & TRANSPORT_PUSH_SET_UPSTREAM)
+		strvec_push(&argv, "--set-upstream");
+	if (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)
+		strvec_push(&argv, "--force-if-includes");
+	if (flags & TRANSPORT_PUSH_ALL)
+		strvec_push(&argv, "--all");
+	if (flags & TRANSPORT_PUSH_MIRROR)
+		strvec_push(&argv, "--mirror");
+
+	if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
+		strvec_push(&argv, "--signed=yes");
+	else if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)
+		strvec_push(&argv, "--signed=if-asked");
+	if (!thin)
+		strvec_push(&argv, "--no-thin");
+
+	if (deleterefs)
+		strvec_push(&argv, "--delete");
+
+	if (receivepack)
+		strvec_pushf(&argv, "--receive-pack=%s", receivepack);
+	if (verbosity >= 2)
+		strvec_push(&argv, "-v");
+	if (verbosity >= 1)
+		strvec_push(&argv, "-v");
+	else if (verbosity < 0)
+		strvec_push(&argv, "-q");
+	if (progress > 0)
+		strvec_push(&argv, "--progress");
+	else if (progress == 0)
+		strvec_push(&argv, "--no-progress");
+
+	if (family == TRANSPORT_FAMILY_IPV4)
+		strvec_push(&argv, "--ipv4");
+	else if (family == TRANSPORT_FAMILY_IPV6)
+		strvec_push(&argv, "--ipv6");
+
+	if (recurse_submodules == RECURSE_SUBMODULES_CHECK)
+		strvec_push(&argv, "--recurse-submodules=check");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
+		strvec_push(&argv, "--recurse-submodules=on-demand");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
+		strvec_push(&argv, "--recurse-submodules=only");
+	else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
+		strvec_push(&argv, "--recurse-submodules=no");
+
+
+	if (tags)
+		strvec_push(&argv, "--tags");
+
+	for (i = 0; i < push_options->nr; i++)
+		strvec_pushf(&argv, "--push-option=%s",
+			     push_options->items[i].string);
+
+	for (i = 0; i < cas.nr; i++) {
+		if (cas.entry[i].use_tracking) {
+			strvec_pushf(&argv, "--force-with-lease=%s",
+				     cas.entry[i].refname);
+		} else if (!is_null_oid(&cas.entry[i].expect)) {
+			strvec_pushf(&argv, "--force-with-lease=%s:%s",
+				     cas.entry[i].refname,
+				     oid_to_hex(&cas.entry[i].expect));
+		} else {
+			strvec_push(&argv, "--force-with-lease");
+		}
+	}
+
+	for (i = 0; i < list->nr; i++) {
+		const char *name = list->items[i].string;
+		struct child_process cmd = CHILD_PROCESS_INIT;
+		int j;
+
+		strvec_pushv(&cmd.args, argv.v);
+		strvec_push(&cmd.args, name);
+
+		for (j = 0; j < refspec_nr; j++)
+			strvec_push(&cmd.args, refspecs[j]);
+
+		if (verbosity >= 0)
+			printf(_("Pushing to %s\n"), name);
+
+		cmd.git_cmd = 1;
+		if (run_command(&cmd)) {
+			error(_("could not push to %s"), name);
+			result = 1;
+		}
+	}
+
+	strvec_clear(&argv);
+	return result;
+}
+
 int cmd_push(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -552,12 +670,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
+	int base_flags;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP;
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -620,39 +739,45 @@ int cmd_push(int argc,
 	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
 		flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
 
-	if (tags)
-		refspec_append(&rs, "refs/tags/*");
-
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
-	}
-
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
-
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
-
-	if (flags & TRANSPORT_PUSH_ALL) {
-		if (argc >= 2)
-			die(_("--all can't be combined with refspecs"));
-	}
-	if (flags & TRANSPORT_PUSH_MIRROR) {
-		if (argc >= 2)
-			die(_("--mirror can't be combined with refspecs"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group)) {
+			/*
+			 * Not a configured remote name or group name.
+			 * Try treating it as a direct URL or path, e.g.
+			 *   git push /tmp/foo.git
+			 *   git push https://github.com/user/repo.git
+			 * pushremote_get() creates an anonymous remote
+			 * from the URL so the loop below can handle it
+			 * identically to a named remote.
+			 */
+			struct remote *r = pushremote_get(repo);
+			if (!r)
+				die(_("bad repository '%s'"), repo);
+			string_list_append(&remote_group, r->name);
+		}
+	} else {
+		struct remote *r = pushremote_get(NULL);
+		if (!r)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"
+			    "\n"
+			    "To push to multiple remotes at once, configure a remote group using\n"
+			    "\n"
+			    "    git config remotes.<groupname> \"<remote1> <remote2>\"\n"
+			    "\n"
+			    "and then push using the group name\n"
+			    "\n"
+			    "    git push <groupname>\n"));
+		string_list_append(&remote_group, r->name);
 	}
 
 	if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -662,10 +787,70 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	if (remote_group.nr == 1) {
+		/*
+		 * Single remote (the common case): run do_push() directly
+		 * in this process.  The loop runs exactly once.
+		 *
+		 * Mirror detection and the --mirror/--all + refspec conflict
+		 * checks are done here.  rs is rebuilt so that per-remote push
+		 * mappings (remote.NAME.push config) are resolved against the
+		 * correct remote.  inner_flags is a snapshot of flags so that a
+		 * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any
+		 * subsequent call.
+		 */
+		base_flags = flags;
+		{
+			int inner_flags = base_flags;
+			struct remote *r = pushremote_get(remote_group.items[0].string);
+			if (!r)
+				die(_("no such remote or remote group: %s"),
+				    remote_group.items[0].string);
+
+			if (r->mirror)
+				inner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+			if (inner_flags & TRANSPORT_PUSH_ALL) {
+				if (argc >= 2)
+					die(_("--all can't be combined with refspecs"));
+			}
+			if (inner_flags & TRANSPORT_PUSH_MIRROR) {
+				if (argc >= 2)
+					die(_("--mirror can't be combined with refspecs"));
+			}
+
+			refspec_clear(&rs);
+			rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+			if (tags)
+				refspec_append(&rs, "refs/tags/*");
+			if (argc > 0)
+				set_refspecs(argv + 1, argc - 1, r);
+
+			rc = do_push(inner_flags, push_options, r);
+		}
+	} else {
+		/*
+		 * Multiple remotes: spawn one "git push <remote> [<refspecs>]"
+		 * subprocess per remote, sequentially.
+		 *
+		 * Options that only make sense for a single transport connection
+		 * are rejected here.
+		 */
+		if (flags & TRANSPORT_PUSH_ATOMIC)
+			die(_("--atomic can only be used when pushing to one remote"));
+
+		rc = push_multiple(&remote_group, push_options, flags,
+				   tags,
+				   argc > 1 ? argv + 1 : NULL,
+				   argc > 1 ? argc - 1 : 0);
+	}
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..bd090627e9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -704,6 +704,7 @@ integration_tests = [
   't5563-simple-http-auth.sh',
   't5564-http-proxy.sh',
   't5565-push-multiple.sh',
+  't5566-push-group.sh',
   't5570-git-daemon.sh',
   't5571-pre-push-hook.sh',
   't5572-pull-submodule.sh',
diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
new file mode 100755
index 0000000000..a7d59352b1
--- /dev/null
+++ b/t/t5566-push-group.sh
@@ -0,0 +1,160 @@
+#!/bin/sh
+
+test_description='push to remote group'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	for i in 1 2 3
+	do
+		git init --bare dest-$i.git &&
+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
+		return 1
+	done &&
+	test_tick &&
+	git commit --allow-empty -m "initial" &&
+	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
+	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
+	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
+	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
+	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
+	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
+	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
+'
+
+test_expect_success 'push to remote group updates all members correctly' '
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push second commit to group updates all members' '
+	test_tick &&
+	git commit --allow-empty -m "second" &&
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push to single remote in group does not affect others' '
+	test_tick &&
+	git commit --allow-empty -m "third" &&
+	git push remote-1 HEAD:refs/heads/main &&
+	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
+	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
+	! test_cmp hash-after-1 hash-after-2
+'
+
+test_expect_success 'mirror remote in group with refspec fails' '
+	git config set remote.remote-1.mirror true &&
+	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "mirror" err &&
+	git config unset remote.remote-1.mirror
+'
+
+test_expect_success 'push.default=current works with group push' '
+	git config set push.default current &&
+	test_tick &&
+	git commit --allow-empty -m "fifth" &&
+	git push all-remotes &&
+	git config unset push.default
+'
+
+test_expect_success '--atomic is rejected for group push' '
+	test_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "atomic" err
+'
+
+test_expect_success 'push continues past rejection to remaining remotes' '
+	for i in c1 c2 c3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
+	git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
+	git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
+	git config set remotes.continue-group "c1 c2 c3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for continue test" &&
+
+	# initial sync
+	git push continue-group HEAD:refs/heads/main &&
+
+	# advance c2 independently
+	git clone dest-c2.git tmp-c2 &&
+	(
+		cd tmp-c2 &&
+		git checkout -b main origin/main &&
+		test_commit c2_independent &&
+		git push origin HEAD:refs/heads/main
+	) &&
+	rm -rf tmp-c2 &&
+
+	test_tick &&
+	git commit --allow-empty -m "local diverging commit" &&
+
+	# push: c2 rejects, others succeed
+	test_must_fail git push continue-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+	git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
+	git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
+	test_cmp expect actual-c1 &&
+	test_cmp expect actual-c3 &&
+
+	# c2 should not have the new commit
+	git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
+	! test_cmp expect actual-c2
+'
+
+test_expect_success 'fatal connection error does not stop remaining remotes' '
+	for i in f1 f2 f3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
+	git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
+	git config set remotes.fatal-group "f1 f2 f3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for fatal test" &&
+
+	# initial sync
+	git push fatal-group HEAD:refs/heads/main &&
+
+	# break f2
+	git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
+
+	test_tick &&
+	git commit --allow-empty -m "after fatal setup" &&
+
+	# overall exit code is non-zero because f2 failed
+	test_must_fail git push fatal-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+
+	# f1 and f3 should both have the new commit — subprocesses are independent
+	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
+	test_cmp expect actual-f1 &&
+	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
+	test_cmp expect actual-f3 &&
+
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
+'
+
+test_done
-- 
2.53.0
Kristoffer HaugsbakkMay 12, 2026, 15:05 UTC in reply to Usman Akinyemi on lore

Re: [RFC PATCH v5 3/3] push: support pushing to a remote group

On Sun, May 3, 2026, at 17:34, Usman Akinyemi wrote:
Show 8 quoted lines
> `git fetch` accepts a remote group name (configured via `remotes.<name>`
> in config) and fetches from each member remote. `git push` has no
> equivalent — it only accepts a single remote name.
>
>[snip]
>
> +This means the user is responsible for ensuring that the sequence of
> +individual pushes makes sense. If `git push r1`` would fail for a given
Double backticks (s/`git push r1``/`git push r1`/).
> +set of options and arguments, then `git push all-remotes` will fail in
> +the same way when it reaches r1. The group push does not do anything
s/r1/`r1`/ ? Goes for the others here as well
Show 5 quoted lines
> +special to make a failing individual push succeed.
> +
>  OUTPUT
>  ------
>[snip]
Usman AkinyemiMay 18, 2026, 18:27 UTC in reply to Usman Akinyemi on lore

[RFC PATCH v5 0/3] push: add support for pushing to remote groups

This RFC series adds support for `git push` to accept a remote group name (as configured via `remotes.<name>` in config) in addition to a single remote name, mirroring the behaviour that `git fetch` has supported for some time.

A user with multiple remotes configured as a group can now do:
    git push all-remotes
instead of pushing to each remote individually, in the same way that:
    git fetch all-remotes
already works.
The series is split into three patches:
  - Patch 1 fix sign-compare warnings in push_cas_option
  - Patch 2 moves `get_remote_group`, `add_remote_or_group`, and the
    `remote_group_data` struct out of builtin/fetch.c and into
    remote.c/remote.h, making them part of the public remote API.
  - Patch 2 extends builtin/push.c to use the newly public
    `add_remote_or_group()` to resolve the repository argument as
    either a single remote or a group, and pushes to each member of
    the group in turn.
Changes in v6:
- fix docs formating 
Range-diff v5 -> v6:
1:  e01126890c = 1:  e01126890c remote: fix sign-compare warnings in push_cas_option
2:  adbce652e6 = 2:  adbce652e6 remote: move remote group resolution to remote.c
3:  a8d5f4b7bd ! 3:  62a4499be6 push: support pushing to a remote group
    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`
     +	...
     +	git push <options> rN <args>
     +
    -+where r1, r2, ..., rN are the members of `all-remotes`.  No special
    ++where `r1`, `r2`, ..., `rN` are the members of `all-remotes`.  No special
     +behaviour is added or removed — the group is purely a shorthand for
     +running the same push command against each member remote individually.
     +
    @@ Documentation/git-push.adoc: further recursion will occur. In this case, `only`
     +any member push fails.
     +
     +This means the user is responsible for ensuring that the sequence of
    -+individual pushes makes sense. If `git push r1`` would fail for a given
    ++individual pushes makes sense. If `git push r1` would fail for a given
     +set of options and arguments, then `git push all-remotes` will fail in
    -+the same way when it reaches r1. The group push does not do anything
    ++the same way when it reaches `r1`. The group push does not do anything
     +special to make a failing individual push succeed.
     +
      OUTPUT
Usman Akinyemi (3):
  remote: fix sign-compare warnings in push_cas_option
  remote: move remote group resolution to remote.c
  push: support pushing to a remote group
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/fetch.c             |  42 ------
 builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----
 remote.c                    |  37 ++++++
 remote.h                    |  16 ++-
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 7 files changed, 502 insertions(+), 85 deletions(-)
 create mode 100755 t/t5566-push-group.sh
-- 
2.54.0
Usman AkinyemiMay 18, 2026, 18:27 UTC in reply to Usman Akinyemi on lore

[PATCH v6 1/3] remote: fix sign-compare warnings in push_cas_option

Replace `int` with `size_t` for `nr` and `alloc` in `struct push_cas_option` to avoid -Werror=sign-compare warnings when comparing against size-based values.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 remote.h | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
Show changes to remote.h +2 −2
diff --git a/remote.h b/remote.h
index fc052945ee..741d14a9fc 100644
--- a/remote.h
+++ b/remote.h
@@ -418,8 +418,8 @@ struct push_cas_option {
 		unsigned use_tracking:1;
 		char *refname;
 	} *entry;
-	int nr;
-	int alloc;
+	size_t nr;
+	size_t alloc;
 };
 
 int parseopt_push_cas_option(const struct option *, const char *arg, int unset);
-- 
2.54.0
Usman AkinyemiMay 18, 2026, 18:27 UTC in reply to Usman Akinyemi on lore

[PATCH v6 2/3] remote: move remote group resolution to remote.c

`get_remote_group`, `add_remote_or_group`, and the `remote_group_data` struct are currently defined as static helpers inside builtin/fetch.c. They implement generic remote group resolution that is not specific to fetch — they parse `remotes.<name>` config entries and resolve a name to either a list of group members or a single configured remote.

Move them to remote.c and declare them in remote.h so that other builtins can use the same logic without duplication.

Useful for the next patch.
Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 builtin/fetch.c | 42 ------------------------------------------
 remote.c        | 37 +++++++++++++++++++++++++++++++++++++
 remote.h        | 12 ++++++++++++
 3 files changed, 49 insertions(+), 42 deletions(-)
Show changes to 3 files +49 −42

builtin/fetch.c, remote.c, remote.h

diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..cfb26eb284 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -2138,48 +2138,6 @@ static int get_one_remote_for_fetch(struct remote *remote, void *priv)
 	return 0;
 }
 
-struct remote_group_data {
-	const char *name;
-	struct string_list *list;
-};
-
-static int get_remote_group(const char *key, const char *value,
-			    const struct config_context *ctx UNUSED,
-			    void *priv)
-{
-	struct remote_group_data *g = priv;
-
-	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
-		/* split list by white space */
-		while (*value) {
-			size_t wordlen = strcspn(value, " \t\n");
-
-			if (wordlen >= 1)
-				string_list_append_nodup(g->list,
-						   xstrndup(value, wordlen));
-			value += wordlen + (value[wordlen] != '\0');
-		}
-	}
-
-	return 0;
-}
-
-static int add_remote_or_group(const char *name, struct string_list *list)
-{
-	int prev_nr = list->nr;
-	struct remote_group_data g;
-	g.name = name; g.list = list;
-
-	repo_config(the_repository, get_remote_group, &g);
-	if (list->nr == prev_nr) {
-		struct remote *remote = remote_get(name);
-		if (!remote_is_configured(remote, 0))
-			return 0;
-		string_list_append(list, remote->name);
-	}
-	return 1;
-}
-
 static void add_options_to_argv(struct strvec *argv,
 				const struct fetch_config *config)
 {
diff --git a/remote.c b/remote.c
index a664cd166a..7133d29332 100644
--- a/remote.c
+++ b/remote.c
@@ -2114,6 +2114,43 @@ int get_fetch_map(const struct ref *remote_refs,
 	return 0;
 }
 
+int get_remote_group(const char *key, const char *value,
+			    const struct config_context *ctx UNUSED,
+			    void *priv)
+{
+	struct remote_group_data *g = priv;
+
+	if (skip_prefix(key, "remotes.", &key) && !strcmp(key, g->name)) {
+		/* split list by white space */
+		while (*value) {
+			size_t wordlen = strcspn(value, " \t\n");
+
+			if (wordlen >= 1)
+				string_list_append_nodup(g->list,
+						   xstrndup(value, wordlen));
+			value += wordlen + (value[wordlen] != '\0');
+		}
+	}
+
+	return 0;
+}
+
+int add_remote_or_group(const char *name, struct string_list *list)
+{
+	int prev_nr = list->nr;
+	struct remote_group_data g;
+	g.name = name; g.list = list;
+
+	repo_config(the_repository, get_remote_group, &g);
+	if (list->nr == prev_nr) {
+		struct remote *remote = remote_get(name);
+		if (!remote_is_configured(remote, 0))
+			return 0;
+		string_list_append(list, remote->name);
+	}
+	return 1;
+}
+
 int resolve_remote_symref(struct ref *ref, struct ref *list)
 {
 	if (!ref->symref)
diff --git a/remote.h b/remote.h
index 741d14a9fc..7915be3111 100644
--- a/remote.h
+++ b/remote.h
@@ -347,6 +347,18 @@ int branch_has_merge_config(struct branch *branch);
 
 int branch_merge_matches(struct branch *, int n, const char *);
 
+/* list of the remote in a group as configured */
+struct remote_group_data {
+	const char *name;
+	struct string_list *list;
+};
+
+int get_remote_group(const char *key, const char *value,
+                    const struct config_context *ctx,
+                    void *priv);
+
+int add_remote_or_group(const char *name, struct string_list *list);
+
 /**
  * Return the fully-qualified refname of the tracking branch for `branch`.
  * I.e., what "branch@{upstream}" would give you. Returns NULL if no
-- 
2.54.0
Usman AkinyemiMay 18, 2026, 18:27 UTC in reply to Usman Akinyemi on lore

[PATCH v6 3/3] push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name.

Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with:

    git push <group>

When the argument resolves to a single remote, the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence.

The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries.

Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com>
---
 Documentation/git-push.adoc |  80 ++++++++++--
 builtin/push.c              | 251 +++++++++++++++++++++++++++++++-----
 t/meson.build               |   1 +
 t/t5566-push-group.sh       | 160 +++++++++++++++++++++++
 4 files changed, 451 insertions(+), 41 deletions(-)
 create mode 100755 t/t5566-push-group.sh
Show changes to 4 files +451 −41

Documentation/git-push.adoc, builtin/push.c, t/meson.build, t/t5566-push-group.sh

diff --git a/Documentation/git-push.adoc b/Documentation/git-push.adoc
index e5ba3a6742..2ac16a9d78 100644
--- a/Documentation/git-push.adoc
+++ b/Documentation/git-push.adoc
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
 
 DESCRIPTION
 -----------
-
-Updates one or more branches, tags, or other references in a remote
-repository from your local repository, and sends all necessary data
-that isn't already on the remote.
+Updates one or more branches, tags, or other references in one or more
+remote repositories from your local repository, and sends all necessary
+data that isn't already on the remote.
 
 The simplest way to push is `git push <remote> <branch>`.
 `git push origin main` will push the local `main` branch to the `main`
 branch on the remote named `origin`.
 
-The `<repository>` argument defaults to the upstream for the current branch,
-or `origin` if there's no configured upstream.
+You can also push to multiple remotes at once by using a remote group.
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "origin gitlab backup"
+
+Then `git push all-remotes` will push to `origin`, `gitlab`, and
+`backup` in turn, as if you had run `git push` against each one
+individually.  Each remote is pushed independently using its own
+push mapping configuration. There is a `remotes.<group>` entry in
+the configuration file. (See linkgit:git-config[1]).
+
+The `<repository>` argument defaults to the upstream for the current
+branch, or `origin` if there's no configured upstream.
 
 To decide which branches, tags, or other refs to push, Git uses
 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
 _<repository>_::
 	The "remote" repository that is the destination of a push
 	operation.  This parameter can be either a URL
-	(see the section <<URLS,GIT URLS>> below) or the name
-	of a remote (see the section <<REMOTES,REMOTES>> below).
+	(see the section <<URLS,GIT URLS>> below), the name
+	of a remote (see the section <<REMOTES,REMOTES>> below),
+	or the name of a remote group
+	(see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
 
 `<refspec>...`::
 	Specify what destination ref to update with what source object.
@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
 
 include::urls-remotes.adoc[]
 
+[[REMOTE-GROUPS]]
+REMOTE GROUPS
+-------------
+
+A remote group is a named list of remotes configured via `remotes.<name>`
+in your git config:
+
+	$ git config remotes.all-remotes "r1 r2 r3"
+
+When a group name is given as the `<repository>` argument, the push is
+performed to each member remote in turn.  The defining principle is:
+
+	git push <options> all-remotes <args>
+
+is exactly equivalent to:
+
+	git push <options> r1 <args>
+	git push <options> r2 <args>
+	...
+	git push <options> rN <args>
+
+where `r1`, `r2`, ..., `rN` are the members of `all-remotes`.  No special
+behaviour is added or removed — the group is purely a shorthand for
+running the same push command against each member remote individually.
+
+When pushing to a group of more than one remote, Git spawns a separate
+`git push` subprocess for each member remote in sequence.  Each subprocess
+receives the same flags and refspecs as the original invocation.  This
+means that per-remote push mappings configured via `remote.<name>.push`
+and mirror mode (`remote.<name>.mirror`) are evaluated independently for
+each remote, and a mirror remote in the group cannot affect the push
+behaviour of other non-mirror remotes in the same group.
+
+The `--atomic` option is not supported for group pushes, because atomicity
+can only be guaranteed within a single transport connection to a single
+remote.  Git will refuse the invocation with an error if `--atomic` is
+combined with a group name.
+
+If any member remote fails whether due to a push rejection (e.g. a
+non-fast-forward update, a server-side hook refusing a ref) or a connection
+error (e.g. the repository does not exist, authentication fails, or the
+network is unreachable), Git reports the error and continues pushing to
+the remaining remotes in the group.  The overall exit code is non-zero if
+any member push fails.
+
+This means the user is responsible for ensuring that the sequence of
+individual pushes makes sense. If `git push r1` would fail for a given
+set of options and arguments, then `git push all-remotes` will fail in
+the same way when it reaches `r1`. The group push does not do anything
+special to make a failing individual push succeed.
+
 OUTPUT
 ------
 
diff --git a/builtin/push.c b/builtin/push.c
index 7100ffba5d..6021b71d66 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -10,6 +10,7 @@
 #include "config.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "refspec.h"
 #include "run-command.h"
 #include "remote.h"
@@ -544,6 +545,123 @@ static int git_push_config(const char *k, const char *v,
 	return git_default_config(k, v, ctx, NULL);
 }
 
+static int push_multiple(struct string_list *list,
+			 const struct string_list *push_options,
+			 int flags,
+			 int tags,
+			 const char **refspecs,
+			 int refspec_nr)
+{
+	int result = 0;
+	size_t i;
+	struct strvec argv = STRVEC_INIT;
+
+	strvec_push(&argv, "push");
+
+	if (flags & TRANSPORT_PUSH_FORCE)
+		strvec_push(&argv, "--force");
+	if (flags & TRANSPORT_PUSH_DRY_RUN)
+		strvec_push(&argv, "--dry-run");
+	if (flags & TRANSPORT_PUSH_PORCELAIN)
+		strvec_push(&argv, "--porcelain");
+	if (flags & TRANSPORT_PUSH_PRUNE)
+		strvec_push(&argv, "--prune");
+	if (flags & TRANSPORT_PUSH_NO_HOOK)
+		strvec_push(&argv, "--no-verify");
+	if (flags & TRANSPORT_PUSH_FOLLOW_TAGS)
+		strvec_push(&argv, "--follow-tags");
+	if (flags & TRANSPORT_PUSH_SET_UPSTREAM)
+		strvec_push(&argv, "--set-upstream");
+	if (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)
+		strvec_push(&argv, "--force-if-includes");
+	if (flags & TRANSPORT_PUSH_ALL)
+		strvec_push(&argv, "--all");
+	if (flags & TRANSPORT_PUSH_MIRROR)
+		strvec_push(&argv, "--mirror");
+
+	if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
+		strvec_push(&argv, "--signed=yes");
+	else if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)
+		strvec_push(&argv, "--signed=if-asked");
+	if (!thin)
+		strvec_push(&argv, "--no-thin");
+
+	if (deleterefs)
+		strvec_push(&argv, "--delete");
+
+	if (receivepack)
+		strvec_pushf(&argv, "--receive-pack=%s", receivepack);
+	if (verbosity >= 2)
+		strvec_push(&argv, "-v");
+	if (verbosity >= 1)
+		strvec_push(&argv, "-v");
+	else if (verbosity < 0)
+		strvec_push(&argv, "-q");
+	if (progress > 0)
+		strvec_push(&argv, "--progress");
+	else if (progress == 0)
+		strvec_push(&argv, "--no-progress");
+
+	if (family == TRANSPORT_FAMILY_IPV4)
+		strvec_push(&argv, "--ipv4");
+	else if (family == TRANSPORT_FAMILY_IPV6)
+		strvec_push(&argv, "--ipv6");
+
+	if (recurse_submodules == RECURSE_SUBMODULES_CHECK)
+		strvec_push(&argv, "--recurse-submodules=check");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
+		strvec_push(&argv, "--recurse-submodules=on-demand");
+	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
+		strvec_push(&argv, "--recurse-submodules=only");
+	else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
+		strvec_push(&argv, "--recurse-submodules=no");
+
+
+	if (tags)
+		strvec_push(&argv, "--tags");
+
+	for (i = 0; i < push_options->nr; i++)
+		strvec_pushf(&argv, "--push-option=%s",
+			     push_options->items[i].string);
+
+	for (i = 0; i < cas.nr; i++) {
+		if (cas.entry[i].use_tracking) {
+			strvec_pushf(&argv, "--force-with-lease=%s",
+				     cas.entry[i].refname);
+		} else if (!is_null_oid(&cas.entry[i].expect)) {
+			strvec_pushf(&argv, "--force-with-lease=%s:%s",
+				     cas.entry[i].refname,
+				     oid_to_hex(&cas.entry[i].expect));
+		} else {
+			strvec_push(&argv, "--force-with-lease");
+		}
+	}
+
+	for (i = 0; i < list->nr; i++) {
+		const char *name = list->items[i].string;
+		struct child_process cmd = CHILD_PROCESS_INIT;
+		int j;
+
+		strvec_pushv(&cmd.args, argv.v);
+		strvec_push(&cmd.args, name);
+
+		for (j = 0; j < refspec_nr; j++)
+			strvec_push(&cmd.args, refspecs[j]);
+
+		if (verbosity >= 0)
+			printf(_("Pushing to %s\n"), name);
+
+		cmd.git_cmd = 1;
+		if (run_command(&cmd)) {
+			error(_("could not push to %s"), name);
+			result = 1;
+		}
+	}
+
+	strvec_clear(&argv);
+	return result;
+}
+
 int cmd_push(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -552,12 +670,13 @@ int cmd_push(int argc,
 	int flags = 0;
 	int tags = 0;
 	int push_cert = -1;
-	int rc;
+	int rc = 0;
+	int base_flags;
 	const char *repo = NULL;	/* default repository */
 	struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
+	struct string_list remote_group = STRING_LIST_INIT_DUP;
 	struct string_list *push_options;
 	const struct string_list_item *item;
-	struct remote *remote;
 
 	struct option options[] = {
 		OPT__VERBOSITY(&verbosity),
@@ -620,39 +739,45 @@ int cmd_push(int argc,
 	else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
 		flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
 
-	if (tags)
-		refspec_append(&rs, "refs/tags/*");
-
 	if (argc > 0)
 		repo = argv[0];
 
-	remote = pushremote_get(repo);
-	if (!remote) {
-		if (repo)
-			die(_("bad repository '%s'"), repo);
-		die(_("No configured push destination.\n"
-		    "Either specify the URL from the command-line or configure a remote repository using\n"
-		    "\n"
-		    "    git remote add <name> <url>\n"
-		    "\n"
-		    "and then push using the remote name\n"
-		    "\n"
-		    "    git push <name>\n"));
-	}
-
-	if (argc > 0)
-		set_refspecs(argv + 1, argc - 1, remote);
-
-	if (remote->mirror)
-		flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
-
-	if (flags & TRANSPORT_PUSH_ALL) {
-		if (argc >= 2)
-			die(_("--all can't be combined with refspecs"));
-	}
-	if (flags & TRANSPORT_PUSH_MIRROR) {
-		if (argc >= 2)
-			die(_("--mirror can't be combined with refspecs"));
+	if (repo) {
+		if (!add_remote_or_group(repo, &remote_group)) {
+			/*
+			 * Not a configured remote name or group name.
+			 * Try treating it as a direct URL or path, e.g.
+			 *   git push /tmp/foo.git
+			 *   git push https://github.com/user/repo.git
+			 * pushremote_get() creates an anonymous remote
+			 * from the URL so the loop below can handle it
+			 * identically to a named remote.
+			 */
+			struct remote *r = pushremote_get(repo);
+			if (!r)
+				die(_("bad repository '%s'"), repo);
+			string_list_append(&remote_group, r->name);
+		}
+	} else {
+		struct remote *r = pushremote_get(NULL);
+		if (!r)
+			die(_("No configured push destination.\n"
+			    "Either specify the URL from the command-line or configure a remote repository using\n"
+			    "\n"
+			    "    git remote add <name> <url>\n"
+			    "\n"
+			    "and then push using the remote name\n"
+			    "\n"
+			    "    git push <name>\n"
+			    "\n"
+			    "To push to multiple remotes at once, configure a remote group using\n"
+			    "\n"
+			    "    git config remotes.<groupname> \"<remote1> <remote2>\"\n"
+			    "\n"
+			    "and then push using the group name\n"
+			    "\n"
+			    "    git push <groupname>\n"));
+		string_list_append(&remote_group, r->name);
 	}
 
 	if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -662,10 +787,70 @@ int cmd_push(int argc,
 		if (strchr(item->string, '\n'))
 			die(_("push options must not have new line characters"));
 
-	rc = do_push(flags, push_options, remote);
+	if (remote_group.nr == 1) {
+		/*
+		 * Single remote (the common case): run do_push() directly
+		 * in this process.  The loop runs exactly once.
+		 *
+		 * Mirror detection and the --mirror/--all + refspec conflict
+		 * checks are done here.  rs is rebuilt so that per-remote push
+		 * mappings (remote.NAME.push config) are resolved against the
+		 * correct remote.  inner_flags is a snapshot of flags so that a
+		 * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any
+		 * subsequent call.
+		 */
+		base_flags = flags;
+		{
+			int inner_flags = base_flags;
+			struct remote *r = pushremote_get(remote_group.items[0].string);
+			if (!r)
+				die(_("no such remote or remote group: %s"),
+				    remote_group.items[0].string);
+
+			if (r->mirror)
+				inner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
+
+			if (inner_flags & TRANSPORT_PUSH_ALL) {
+				if (argc >= 2)
+					die(_("--all can't be combined with refspecs"));
+			}
+			if (inner_flags & TRANSPORT_PUSH_MIRROR) {
+				if (argc >= 2)
+					die(_("--mirror can't be combined with refspecs"));
+			}
+
+			refspec_clear(&rs);
+			rs = (struct refspec) REFSPEC_INIT_PUSH;
+
+			if (tags)
+				refspec_append(&rs, "refs/tags/*");
+			if (argc > 0)
+				set_refspecs(argv + 1, argc - 1, r);
+
+			rc = do_push(inner_flags, push_options, r);
+		}
+	} else {
+		/*
+		 * Multiple remotes: spawn one "git push <remote> [<refspecs>]"
+		 * subprocess per remote, sequentially.
+		 *
+		 * Options that only make sense for a single transport connection
+		 * are rejected here.
+		 */
+		if (flags & TRANSPORT_PUSH_ATOMIC)
+			die(_("--atomic can only be used when pushing to one remote"));
+
+		rc = push_multiple(&remote_group, push_options, flags,
+				   tags,
+				   argc > 1 ? argv + 1 : NULL,
+				   argc > 1 ? argc - 1 : 0);
+	}
+
 	string_list_clear(&push_options_cmdline, 0);
 	string_list_clear(&push_options_config, 0);
+	string_list_clear(&remote_group, 0);
 	clear_cas_option(&cas);
+
 	if (rc == -1)
 		usage_with_options(push_usage, options);
 	else
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..bd090627e9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -704,6 +704,7 @@ integration_tests = [
   't5563-simple-http-auth.sh',
   't5564-http-proxy.sh',
   't5565-push-multiple.sh',
+  't5566-push-group.sh',
   't5570-git-daemon.sh',
   't5571-pre-push-hook.sh',
   't5572-pull-submodule.sh',
diff --git a/t/t5566-push-group.sh b/t/t5566-push-group.sh
new file mode 100755
index 0000000000..a7d59352b1
--- /dev/null
+++ b/t/t5566-push-group.sh
@@ -0,0 +1,160 @@
+#!/bin/sh
+
+test_description='push to remote group'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	for i in 1 2 3
+	do
+		git init --bare dest-$i.git &&
+		git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
+		return 1
+	done &&
+	test_tick &&
+	git commit --allow-empty -m "initial" &&
+	git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
+	git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
+	git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
+	git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
+	git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
+	git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
+	git config set remotes.all-remotes "remote-1 remote-2 remote-3"
+'
+
+test_expect_success 'push to remote group updates all members correctly' '
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push second commit to group updates all members' '
+	test_tick &&
+	git commit --allow-empty -m "second" &&
+	git push all-remotes HEAD:refs/heads/main &&
+	git rev-parse HEAD >expect &&
+	for i in 1 2 3
+	do
+		git -C dest-$i.git rev-parse refs/heads/main >actual ||
+		return 1
+		test_cmp expect actual || return 1
+	done
+'
+
+test_expect_success 'push to single remote in group does not affect others' '
+	test_tick &&
+	git commit --allow-empty -m "third" &&
+	git push remote-1 HEAD:refs/heads/main &&
+	git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
+	git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
+	! test_cmp hash-after-1 hash-after-2
+'
+
+test_expect_success 'mirror remote in group with refspec fails' '
+	git config set remote.remote-1.mirror true &&
+	test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "mirror" err &&
+	git config unset remote.remote-1.mirror
+'
+
+test_expect_success 'push.default=current works with group push' '
+	git config set push.default current &&
+	test_tick &&
+	git commit --allow-empty -m "fifth" &&
+	git push all-remotes &&
+	git config unset push.default
+'
+
+test_expect_success '--atomic is rejected for group push' '
+	test_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&
+	test_grep "atomic" err
+'
+
+test_expect_success 'push continues past rejection to remaining remotes' '
+	for i in c1 c2 c3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
+	git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
+	git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
+	git config set remotes.continue-group "c1 c2 c3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for continue test" &&
+
+	# initial sync
+	git push continue-group HEAD:refs/heads/main &&
+
+	# advance c2 independently
+	git clone dest-c2.git tmp-c2 &&
+	(
+		cd tmp-c2 &&
+		git checkout -b main origin/main &&
+		test_commit c2_independent &&
+		git push origin HEAD:refs/heads/main
+	) &&
+	rm -rf tmp-c2 &&
+
+	test_tick &&
+	git commit --allow-empty -m "local diverging commit" &&
+
+	# push: c2 rejects, others succeed
+	test_must_fail git push continue-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+	git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
+	git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
+	test_cmp expect actual-c1 &&
+	test_cmp expect actual-c3 &&
+
+	# c2 should not have the new commit
+	git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
+	! test_cmp expect actual-c2
+'
+
+test_expect_success 'fatal connection error does not stop remaining remotes' '
+	for i in f1 f2 f3
+	do
+		git init --bare dest-$i.git || return 1
+	done &&
+	git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
+	git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
+	git config set remotes.fatal-group "f1 f2 f3" &&
+
+	test_tick &&
+	git commit --allow-empty -m "base for fatal test" &&
+
+	# initial sync
+	git push fatal-group HEAD:refs/heads/main &&
+
+	# break f2
+	git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
+
+	test_tick &&
+	git commit --allow-empty -m "after fatal setup" &&
+
+	# overall exit code is non-zero because f2 failed
+	test_must_fail git push fatal-group HEAD:refs/heads/main &&
+
+	git rev-parse HEAD >expect &&
+
+	# f1 and f3 should both have the new commit — subprocesses are independent
+	git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
+	test_cmp expect actual-f1 &&
+	git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
+	test_cmp expect actual-f3 &&
+
+	git config set remote.f2.url "file://$(pwd)/dest-f2.git"
+'
+
+test_done
-- 
2.54.0

Back to recent threads