public inbox for git@vger.kernel.org
 help / color / mirror / Atom feed
* [RFC PATCH 0/2] push: add support for pushing to remote groups
@ 2026-03-05 22:32 Usman Akinyemi
  2026-03-05 22:32 ` [RFC PATCH 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
                   ` (2 more replies)
  0 siblings, 3 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-05 22:32 UTC (permalink / raw)
  To: git, gitster; +Cc: christian.couder, me, phillip.wood123, ps

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


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [RFC PATCH 1/2] remote: move remote group resolution to remote.c
  2026-03-05 22:32 [RFC PATCH 0/2] push: add support for pushing to remote groups Usman Akinyemi
@ 2026-03-05 22:32 ` Usman Akinyemi
  2026-03-06 18:12   ` Junio C Hamano
  2026-03-05 22:32 ` [RFC PATCH 2/2] push: support pushing to a remote group Usman Akinyemi
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2 siblings, 1 reply; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-05 22:32 UTC (permalink / raw)
  To: git, gitster; +Cc: christian.couder, me, phillip.wood123, ps

`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(-)

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [RFC PATCH 2/2] push: support pushing to a remote group
  2026-03-05 22:32 [RFC PATCH 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2026-03-05 22:32 ` [RFC PATCH 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
@ 2026-03-05 22:32 ` Usman Akinyemi
  2026-03-07  2:12   ` Junio C Hamano
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2 siblings, 1 reply; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-05 22:32 UTC (permalink / raw)
  To: git, gitster; +Cc: christian.couder, me, phillip.wood123, ps

`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(-)

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c
  2026-03-05 22:32 ` [RFC PATCH 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
@ 2026-03-06 18:12   ` Junio C Hamano
  2026-03-09  0:43     ` Usman Akinyemi
  0 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-03-06 18:12 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> 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.


^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH 2/2] push: support pushing to a remote group
  2026-03-05 22:32 ` [RFC PATCH 2/2] push: support pushing to a remote group Usman Akinyemi
@ 2026-03-07  2:12   ` Junio C Hamano
  2026-03-09  0:56     ` Usman Akinyemi
  0 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-03-07  2:12 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> -	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 ...

> +	/*
> +	 * 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.

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH 1/2] remote: move remote group resolution to remote.c
  2026-03-06 18:12   ` Junio C Hamano
@ 2026-03-09  0:43     ` Usman Akinyemi
  0 siblings, 0 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-09  0:43 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, christian.couder, me, phillip.wood123, ps

> > +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.
>
> 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.
>

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH 2/2] push: support pushing to a remote group
  2026-03-07  2:12   ` Junio C Hamano
@ 2026-03-09  0:56     ` Usman Akinyemi
  2026-03-09 13:38       ` Junio C Hamano
  0 siblings, 1 reply; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-09  0:56 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, christian.couder, me, phillip.wood123, ps

>
> 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.
>
> 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

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH 2/2] push: support pushing to a remote group
  2026-03-09  0:56     ` Usman Akinyemi
@ 2026-03-09 13:38       ` Junio C Hamano
  0 siblings, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-09 13:38 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> 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).





^ permalink raw reply	[flat|nested] 23+ messages in thread

* [RFC PATCH v2 0/2] push: add support for pushing to remote groups
  2026-03-05 22:32 [RFC PATCH 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2026-03-05 22:32 ` [RFC PATCH 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
  2026-03-05 22:32 ` [RFC PATCH 2/2] push: support pushing to a remote group Usman Akinyemi
@ 2026-03-18 20:40 ` Usman Akinyemi
  2026-03-18 20:40   ` [RFC PATCH v2 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
                     ` (3 more replies)
  2 siblings, 4 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-18 20:40 UTC (permalink / raw)
  To: git, gitster; +Cc: usmanakinyemi202, christian.couder, me, phillip.wood123, ps

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


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [RFC PATCH v2 1/2] remote: move remote group resolution to remote.c
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
@ 2026-03-18 20:40   ` Usman Akinyemi
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
                     ` (2 subsequent siblings)
  3 siblings, 0 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-18 20:40 UTC (permalink / raw)
  To: git, gitster; +Cc: usmanakinyemi202, christian.couder, me, phillip.wood123, ps

`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(-)

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2026-03-18 20:40   ` [RFC PATCH v2 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
@ 2026-03-18 20:40   ` Usman Akinyemi
  2026-03-18 20:57     ` Junio C Hamano
                       ` (3 more replies)
  2026-03-18 21:57   ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Junio C Hamano
  2026-03-25 19:09   ` [RFC PATCH v3 " Usman Akinyemi
  3 siblings, 4 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-18 20:40 UTC (permalink / raw)
  To: git, gitster; +Cc: usmanakinyemi202, christian.couder, me, phillip.wood123, ps

`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

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
@ 2026-03-18 20:57     ` Junio C Hamano
  2026-03-18 21:58     ` Junio C Hamano
                       ` (2 subsequent siblings)
  3 siblings, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-18 20:57 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> 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?

> +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).

> 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).

> 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.

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
  2026-03-18 20:40   ` [RFC PATCH v2 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
@ 2026-03-18 21:57   ` Junio C Hamano
  2026-03-18 23:13     ` Usman Akinyemi
  2026-03-25 19:09   ` [RFC PATCH v3 " Usman Akinyemi
  3 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-03-18 21:57 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> 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?

> 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).

>   - 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

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
  2026-03-18 20:57     ` Junio C Hamano
@ 2026-03-18 21:58     ` Junio C Hamano
  2026-03-18 22:25     ` Junio C Hamano
  2026-03-19 17:02     ` Junio C Hamano
  3 siblings, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-18 21:58 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

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".

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
  2026-03-18 20:57     ` Junio C Hamano
  2026-03-18 21:58     ` Junio C Hamano
@ 2026-03-18 22:25     ` Junio C Hamano
  2026-03-19 17:02     ` Junio C Hamano
  3 siblings, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-18 22:25 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> 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?

> +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

> +
> +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).

> +	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??

> +	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

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 0/2] push: add support for pushing to remote groups
  2026-03-18 21:57   ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Junio C Hamano
@ 2026-03-18 23:13     ` Usman Akinyemi
  0 siblings, 0 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-18 23:13 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, christian.couder, me, phillip.wood123, ps

>
> >   - 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.
>
> >
> >   - 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.
>

> >     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.

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
                       ` (2 preceding siblings ...)
  2026-03-18 22:25     ` Junio C Hamano
@ 2026-03-19 17:02     ` Junio C Hamano
  2026-03-25 18:42       ` Usman Akinyemi
  3 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-03-19 17:02 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: git, christian.couder, me, phillip.wood123, ps

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"

^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v2 2/2] push: support pushing to a remote group
  2026-03-19 17:02     ` Junio C Hamano
@ 2026-03-25 18:42       ` Usman Akinyemi
  0 siblings, 0 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-25 18:42 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, christian.couder, me, phillip.wood123, ps

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.

^ permalink raw reply	[flat|nested] 23+ messages in thread

* [RFC PATCH v3 0/2] push: add support for pushing to remote groups
  2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
                     ` (2 preceding siblings ...)
  2026-03-18 21:57   ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Junio C Hamano
@ 2026-03-25 19:09   ` Usman Akinyemi
  2026-03-25 19:09     ` [RFC PATCH v3 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
  2026-03-25 19:09     ` [RFC PATCH v3 2/2] push: support pushing to a remote group Usman Akinyemi
  3 siblings, 2 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-25 19:09 UTC (permalink / raw)
  To: usmanakinyemi202; +Cc: christian.couder, git, gitster, me, phillip.wood123, ps

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


^ permalink raw reply	[flat|nested] 23+ messages in thread

* [RFC PATCH v3 1/2] remote: move remote group resolution to remote.c
  2026-03-25 19:09   ` [RFC PATCH v3 " Usman Akinyemi
@ 2026-03-25 19:09     ` Usman Akinyemi
  2026-03-25 19:09     ` [RFC PATCH v3 2/2] push: support pushing to a remote group Usman Akinyemi
  1 sibling, 0 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-25 19:09 UTC (permalink / raw)
  To: usmanakinyemi202; +Cc: christian.couder, git, gitster, me, phillip.wood123, ps

`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(-)

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* [RFC PATCH v3 2/2] push: support pushing to a remote group
  2026-03-25 19:09   ` [RFC PATCH v3 " Usman Akinyemi
  2026-03-25 19:09     ` [RFC PATCH v3 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
@ 2026-03-25 19:09     ` Usman Akinyemi
  2026-03-25 19:47       ` Junio C Hamano
  2026-03-27 22:18       ` Junio C Hamano
  1 sibling, 2 replies; 23+ messages in thread
From: Usman Akinyemi @ 2026-03-25 19:09 UTC (permalink / raw)
  To: usmanakinyemi202; +Cc: christian.couder, git, gitster, me, phillip.wood123, ps

`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

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


^ permalink raw reply related	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v3 2/2] push: support pushing to a remote group
  2026-03-25 19:09     ` [RFC PATCH v3 2/2] push: support pushing to a remote group Usman Akinyemi
@ 2026-03-25 19:47       ` Junio C Hamano
  2026-03-27 22:18       ` Junio C Hamano
  1 sibling, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-25 19:47 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: christian.couder, git, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

> `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

> 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.

> +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.

> +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.


^ permalink raw reply	[flat|nested] 23+ messages in thread

* Re: [RFC PATCH v3 2/2] push: support pushing to a remote group
  2026-03-25 19:09     ` [RFC PATCH v3 2/2] push: support pushing to a remote group Usman Akinyemi
  2026-03-25 19:47       ` Junio C Hamano
@ 2026-03-27 22:18       ` Junio C Hamano
  1 sibling, 0 replies; 23+ messages in thread
From: Junio C Hamano @ 2026-03-27 22:18 UTC (permalink / raw)
  To: Usman Akinyemi; +Cc: christian.couder, git, me, phillip.wood123, ps

Usman Akinyemi <usmanakinyemi202@gmail.com> writes:

>  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.

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' '



^ permalink raw reply related	[flat|nested] 23+ messages in thread

end of thread, other threads:[~2026-03-27 22:18 UTC | newest]

Thread overview: 23+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-03-05 22:32 [RFC PATCH 0/2] push: add support for pushing to remote groups Usman Akinyemi
2026-03-05 22:32 ` [RFC PATCH 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
2026-03-06 18:12   ` Junio C Hamano
2026-03-09  0:43     ` Usman Akinyemi
2026-03-05 22:32 ` [RFC PATCH 2/2] push: support pushing to a remote group Usman Akinyemi
2026-03-07  2:12   ` Junio C Hamano
2026-03-09  0:56     ` Usman Akinyemi
2026-03-09 13:38       ` Junio C Hamano
2026-03-18 20:40 ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Usman Akinyemi
2026-03-18 20:40   ` [RFC PATCH v2 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
2026-03-18 20:40   ` [RFC PATCH v2 2/2] push: support pushing to a remote group Usman Akinyemi
2026-03-18 20:57     ` Junio C Hamano
2026-03-18 21:58     ` Junio C Hamano
2026-03-18 22:25     ` Junio C Hamano
2026-03-19 17:02     ` Junio C Hamano
2026-03-25 18:42       ` Usman Akinyemi
2026-03-18 21:57   ` [RFC PATCH v2 0/2] push: add support for pushing to remote groups Junio C Hamano
2026-03-18 23:13     ` Usman Akinyemi
2026-03-25 19:09   ` [RFC PATCH v3 " Usman Akinyemi
2026-03-25 19:09     ` [RFC PATCH v3 1/2] remote: move remote group resolution to remote.c Usman Akinyemi
2026-03-25 19:09     ` [RFC PATCH v3 2/2] push: support pushing to a remote group Usman Akinyemi
2026-03-25 19:47       ` Junio C Hamano
2026-03-27 22:18       ` Junio C Hamano

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox