From: Usman Akinyemi <usmanakinyemi202@gmail.com>
To: usmanakinyemi202@gmail.com
Cc: christian.couder@gmail.com, git@vger.kernel.org,
gitster@pobox.com, me@ttaylorr.com, phillip.wood123@gmail.com,
ps@pks.im
Subject: [RFC PATCH v3 2/2] push: support pushing to a remote group
Date: Thu, 26 Mar 2026 00:39:06 +0530 [thread overview]
Message-ID: <20260325190906.1153080-3-usmanakinyemi202@gmail.com> (raw)
In-Reply-To: <20260325190906.1153080-1-usmanakinyemi202@gmail.com>
`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
next prev parent reply other threads:[~2026-03-25 19:09 UTC|newest]
Thread overview: 23+ messages / expand[flat|nested] mbox.gz Atom feed top
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 ` Usman Akinyemi [this message]
2026-03-25 19:47 ` [RFC PATCH v3 2/2] push: support pushing to a remote group Junio C Hamano
2026-03-27 22:18 ` Junio C Hamano
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260325190906.1153080-3-usmanakinyemi202@gmail.com \
--to=usmanakinyemi202@gmail.com \
--cc=christian.couder@gmail.com \
--cc=git@vger.kernel.org \
--cc=gitster@pobox.com \
--cc=me@ttaylorr.com \
--cc=phillip.wood123@gmail.com \
--cc=ps@pks.im \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox