push: support pushing to a remote group

`git fetch` accepts a remote group name (configured via `remotes.<name>` in config) and fetches from each member remote. `git push` has no equivalent — it only accepts a single remote name. Teach `git push` to resolve its repository argument through `add_remote_or_group()`, which was made public in the previous patch, so that a user can push to all remotes in a group with: git push <group> When the argument resolves to a single remote, the behaviour is identical to before. When it resolves to a group, each member remote is pushed in sequence. The group push path rebuilds the refspec list (`rs`) from scratch for each member remote so that per-remote push mappings configured via `remote.<name>.push` are resolved correctly against each specific remote. Without this, refspec entries would accumulate across iterations and each subsequent remote would receive a growing list of duplicated entries. Mirror detection (`remote->mirror`) is also evaluated per remote using a copy of the flags, so that a mirror remote in the group cannot set TRANSPORT_PUSH_FORCE on subsequent non-mirror remotes in the same group. Suggested-by: Junio C Hamano <gitster@pobox.com> Signed-off-by: Usman Akinyemi <usmanakinyemi202@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Usman Akinyemi committed May 3, 2026 at 21:04 UTC 8ea82816652d20ac7070a8fcd60980568a8a293c
4 files changed +451 -41
Documentation/git-push.adoc
+72 -8
@@ -18,17 +18,28 @@ git push [--all | --branches | --mirror | --tags] [--follow-tags] [--atomic] [-n
18
19 DESCRIPTION
20 -----------
21 -
22 -Updates one or more branches, tags, or other references in a remote
23 -repository from your local repository, and sends all necessary data
24 -that isn't already on the remote.
21 +Updates one or more branches, tags, or other references in one or more
22 +remote repositories from your local repository, and sends all necessary
23 +data that isn't already on the remote.
24
25 The simplest way to push is `git push <remote> <branch>`.
26 `git push origin main` will push the local `main` branch to the `main`
27 branch on the remote named `origin`.
28
30 -The `<repository>` argument defaults to the upstream for the current branch,
31 -or `origin` if there's no configured upstream.
29 +You can also push to multiple remotes at once by using a remote group.
30 +A remote group is a named list of remotes configured via `remotes.<name>`
31 +in your git config:
32 +
33 + $ git config remotes.all-remotes "origin gitlab backup"
34 +
35 +Then `git push all-remotes` will push to `origin`, `gitlab`, and
36 +`backup` in turn, as if you had run `git push` against each one
37 +individually. Each remote is pushed independently using its own
38 +push mapping configuration. There is a `remotes.<group>` entry in
39 +the configuration file. (See linkgit:git-config[1]).
40 +
41 +The `<repository>` argument defaults to the upstream for the current
42 +branch, or `origin` if there's no configured upstream.
43
44 To decide which branches, tags, or other refs to push, Git uses
45 (in order of precedence):
@@ -55,8 +66,10 @@ OPTIONS
66 _<repository>_::
67 The "remote" repository that is the destination of a push
68 operation. This parameter can be either a URL
58 - (see the section <<URLS,GIT URLS>> below) or the name
59 - of a remote (see the section <<REMOTES,REMOTES>> below).
69 + (see the section <<URLS,GIT URLS>> below), the name
70 + of a remote (see the section <<REMOTES,REMOTES>> below),
71 + or the name of a remote group
72 + (see the section <<REMOTE-GROUPS,REMOTE GROUPS>> below).
73
74 `<refspec>...`::
75 Specify what destination ref to update with what source object.
@@ -430,6 +443,57 @@ further recursion will occur. In this case, `only` is treated as `on-demand`.
443
444 include::urls-remotes.adoc[]
445
446 +[[REMOTE-GROUPS]]
447 +REMOTE GROUPS
448 +-------------
449 +
450 +A remote group is a named list of remotes configured via `remotes.<name>`
451 +in your git config:
452 +
453 + $ git config remotes.all-remotes "r1 r2 r3"
454 +
455 +When a group name is given as the `<repository>` argument, the push is
456 +performed to each member remote in turn. The defining principle is:
457 +
458 + git push <options> all-remotes <args>
459 +
460 +is exactly equivalent to:
461 +
462 + git push <options> r1 <args>
463 + git push <options> r2 <args>
464 + ...
465 + git push <options> rN <args>
466 +
467 +where r1, r2, ..., rN are the members of `all-remotes`. No special
468 +behaviour is added or removed — the group is purely a shorthand for
469 +running the same push command against each member remote individually.
470 +
471 +When pushing to a group of more than one remote, Git spawns a separate
472 +`git push` subprocess for each member remote in sequence. Each subprocess
473 +receives the same flags and refspecs as the original invocation. This
474 +means that per-remote push mappings configured via `remote.<name>.push`
475 +and mirror mode (`remote.<name>.mirror`) are evaluated independently for
476 +each remote, and a mirror remote in the group cannot affect the push
477 +behaviour of other non-mirror remotes in the same group.
478 +
479 +The `--atomic` option is not supported for group pushes, because atomicity
480 +can only be guaranteed within a single transport connection to a single
481 +remote. Git will refuse the invocation with an error if `--atomic` is
482 +combined with a group name.
483 +
484 +If any member remote fails whether due to a push rejection (e.g. a
485 +non-fast-forward update, a server-side hook refusing a ref) or a connection
486 +error (e.g. the repository does not exist, authentication fails, or the
487 +network is unreachable), Git reports the error and continues pushing to
488 +the remaining remotes in the group. The overall exit code is non-zero if
489 +any member push fails.
490 +
491 +This means the user is responsible for ensuring that the sequence of
492 +individual pushes makes sense. If `git push r1`` would fail for a given
493 +set of options and arguments, then `git push all-remotes` will fail in
494 +the same way when it reaches r1. The group push does not do anything
495 +special to make a failing individual push succeed.
496 +
497 OUTPUT
498 ------
499
builtin/push.c
+218 -33
@@ -10,6 +10,7 @@
10 #include "config.h"
11 #include "environment.h"
12 #include "gettext.h"
13 +#include "hex.h"
14 #include "refspec.h"
15 #include "run-command.h"
16 #include "remote.h"
@@ -544,6 +545,123 @@ static int git_push_config(const char *k, const char *v,
545 return git_default_config(k, v, ctx, NULL);
546 }
547
548 +static int push_multiple(struct string_list *list,
549 + const struct string_list *push_options,
550 + int flags,
551 + int tags,
552 + const char **refspecs,
553 + int refspec_nr)
554 +{
555 + int result = 0;
556 + size_t i;
557 + struct strvec argv = STRVEC_INIT;
558 +
559 + strvec_push(&argv, "push");
560 +
561 + if (flags & TRANSPORT_PUSH_FORCE)
562 + strvec_push(&argv, "--force");
563 + if (flags & TRANSPORT_PUSH_DRY_RUN)
564 + strvec_push(&argv, "--dry-run");
565 + if (flags & TRANSPORT_PUSH_PORCELAIN)
566 + strvec_push(&argv, "--porcelain");
567 + if (flags & TRANSPORT_PUSH_PRUNE)
568 + strvec_push(&argv, "--prune");
569 + if (flags & TRANSPORT_PUSH_NO_HOOK)
570 + strvec_push(&argv, "--no-verify");
571 + if (flags & TRANSPORT_PUSH_FOLLOW_TAGS)
572 + strvec_push(&argv, "--follow-tags");
573 + if (flags & TRANSPORT_PUSH_SET_UPSTREAM)
574 + strvec_push(&argv, "--set-upstream");
575 + if (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES)
576 + strvec_push(&argv, "--force-if-includes");
577 + if (flags & TRANSPORT_PUSH_ALL)
578 + strvec_push(&argv, "--all");
579 + if (flags & TRANSPORT_PUSH_MIRROR)
580 + strvec_push(&argv, "--mirror");
581 +
582 + if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
583 + strvec_push(&argv, "--signed=yes");
584 + else if (flags & TRANSPORT_PUSH_CERT_IF_ASKED)
585 + strvec_push(&argv, "--signed=if-asked");
586 + if (!thin)
587 + strvec_push(&argv, "--no-thin");
588 +
589 + if (deleterefs)
590 + strvec_push(&argv, "--delete");
591 +
592 + if (receivepack)
593 + strvec_pushf(&argv, "--receive-pack=%s", receivepack);
594 + if (verbosity >= 2)
595 + strvec_push(&argv, "-v");
596 + if (verbosity >= 1)
597 + strvec_push(&argv, "-v");
598 + else if (verbosity < 0)
599 + strvec_push(&argv, "-q");
600 + if (progress > 0)
601 + strvec_push(&argv, "--progress");
602 + else if (progress == 0)
603 + strvec_push(&argv, "--no-progress");
604 +
605 + if (family == TRANSPORT_FAMILY_IPV4)
606 + strvec_push(&argv, "--ipv4");
607 + else if (family == TRANSPORT_FAMILY_IPV6)
608 + strvec_push(&argv, "--ipv6");
609 +
610 + if (recurse_submodules == RECURSE_SUBMODULES_CHECK)
611 + strvec_push(&argv, "--recurse-submodules=check");
612 + else if (recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
613 + strvec_push(&argv, "--recurse-submodules=on-demand");
614 + else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
615 + strvec_push(&argv, "--recurse-submodules=only");
616 + else if (recurse_submodules == RECURSE_SUBMODULES_OFF)
617 + strvec_push(&argv, "--recurse-submodules=no");
618 +
619 +
620 + if (tags)
621 + strvec_push(&argv, "--tags");
622 +
623 + for (i = 0; i < push_options->nr; i++)
624 + strvec_pushf(&argv, "--push-option=%s",
625 + push_options->items[i].string);
626 +
627 + for (i = 0; i < cas.nr; i++) {
628 + if (cas.entry[i].use_tracking) {
629 + strvec_pushf(&argv, "--force-with-lease=%s",
630 + cas.entry[i].refname);
631 + } else if (!is_null_oid(&cas.entry[i].expect)) {
632 + strvec_pushf(&argv, "--force-with-lease=%s:%s",
633 + cas.entry[i].refname,
634 + oid_to_hex(&cas.entry[i].expect));
635 + } else {
636 + strvec_push(&argv, "--force-with-lease");
637 + }
638 + }
639 +
640 + for (i = 0; i < list->nr; i++) {
641 + const char *name = list->items[i].string;
642 + struct child_process cmd = CHILD_PROCESS_INIT;
643 + int j;
644 +
645 + strvec_pushv(&cmd.args, argv.v);
646 + strvec_push(&cmd.args, name);
647 +
648 + for (j = 0; j < refspec_nr; j++)
649 + strvec_push(&cmd.args, refspecs[j]);
650 +
651 + if (verbosity >= 0)
652 + printf(_("Pushing to %s\n"), name);
653 +
654 + cmd.git_cmd = 1;
655 + if (run_command(&cmd)) {
656 + error(_("could not push to %s"), name);
657 + result = 1;
658 + }
659 + }
660 +
661 + strvec_clear(&argv);
662 + return result;
663 +}
664 +
665 int cmd_push(int argc,
666 const char **argv,
667 const char *prefix,
@@ -552,12 +670,13 @@ int cmd_push(int argc,
670 int flags = 0;
671 int tags = 0;
672 int push_cert = -1;
555 - int rc;
673 + int rc = 0;
674 + int base_flags;
675 const char *repo = NULL; /* default repository */
676 struct string_list push_options_cmdline = STRING_LIST_INIT_DUP;
677 + struct string_list remote_group = STRING_LIST_INIT_DUP;
678 struct string_list *push_options;
679 const struct string_list_item *item;
560 - struct remote *remote;
680
681 struct option options[] = {
682 OPT__VERBOSITY(&verbosity),
@@ -620,39 +739,45 @@ int cmd_push(int argc,
739 else if (recurse_submodules == RECURSE_SUBMODULES_ONLY)
740 flags |= TRANSPORT_RECURSE_SUBMODULES_ONLY;
741
623 - if (tags)
624 - refspec_append(&rs, "refs/tags/*");
625 -
742 if (argc > 0)
743 repo = argv[0];
744
629 - remote = pushremote_get(repo);
630 - if (!remote) {
631 - if (repo)
632 - die(_("bad repository '%s'"), repo);
633 - die(_("No configured push destination.\n"
634 - "Either specify the URL from the command-line or configure a remote repository using\n"
635 - "\n"
636 - " git remote add <name> <url>\n"
637 - "\n"
638 - "and then push using the remote name\n"
639 - "\n"
640 - " git push <name>\n"));
641 - }
642 -
643 - if (argc > 0)
644 - set_refspecs(argv + 1, argc - 1, remote);
645 -
646 - if (remote->mirror)
647 - flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
648 -
649 - if (flags & TRANSPORT_PUSH_ALL) {
650 - if (argc >= 2)
651 - die(_("--all can't be combined with refspecs"));
652 - }
653 - if (flags & TRANSPORT_PUSH_MIRROR) {
654 - if (argc >= 2)
655 - die(_("--mirror can't be combined with refspecs"));
745 + if (repo) {
746 + if (!add_remote_or_group(repo, &remote_group)) {
747 + /*
748 + * Not a configured remote name or group name.
749 + * Try treating it as a direct URL or path, e.g.
750 + * git push /tmp/foo.git
751 + * git push https://github.com/user/repo.git
752 + * pushremote_get() creates an anonymous remote
753 + * from the URL so the loop below can handle it
754 + * identically to a named remote.
755 + */
756 + struct remote *r = pushremote_get(repo);
757 + if (!r)
758 + die(_("bad repository '%s'"), repo);
759 + string_list_append(&remote_group, r->name);
760 + }
761 + } else {
762 + struct remote *r = pushremote_get(NULL);
763 + if (!r)
764 + die(_("No configured push destination.\n"
765 + "Either specify the URL from the command-line or configure a remote repository using\n"
766 + "\n"
767 + " git remote add <name> <url>\n"
768 + "\n"
769 + "and then push using the remote name\n"
770 + "\n"
771 + " git push <name>\n"
772 + "\n"
773 + "To push to multiple remotes at once, configure a remote group using\n"
774 + "\n"
775 + " git config remotes.<groupname> \"<remote1> <remote2>\"\n"
776 + "\n"
777 + "and then push using the group name\n"
778 + "\n"
779 + " git push <groupname>\n"));
780 + string_list_append(&remote_group, r->name);
781 }
782
783 if (!is_empty_cas(&cas) && (flags & TRANSPORT_PUSH_FORCE_IF_INCLUDES))
@@ -662,10 +787,70 @@ int cmd_push(int argc,
787 if (strchr(item->string, '\n'))
788 die(_("push options must not have new line characters"));
789
665 - rc = do_push(flags, push_options, remote);
790 + if (remote_group.nr == 1) {
791 + /*
792 + * Single remote (the common case): run do_push() directly
793 + * in this process. The loop runs exactly once.
794 + *
795 + * Mirror detection and the --mirror/--all + refspec conflict
796 + * checks are done here. rs is rebuilt so that per-remote push
797 + * mappings (remote.NAME.push config) are resolved against the
798 + * correct remote. inner_flags is a snapshot of flags so that a
799 + * mirror remote cannot bleed TRANSPORT_PUSH_FORCE into any
800 + * subsequent call.
801 + */
802 + base_flags = flags;
803 + {
804 + int inner_flags = base_flags;
805 + struct remote *r = pushremote_get(remote_group.items[0].string);
806 + if (!r)
807 + die(_("no such remote or remote group: %s"),
808 + remote_group.items[0].string);
809 +
810 + if (r->mirror)
811 + inner_flags |= (TRANSPORT_PUSH_MIRROR|TRANSPORT_PUSH_FORCE);
812 +
813 + if (inner_flags & TRANSPORT_PUSH_ALL) {
814 + if (argc >= 2)
815 + die(_("--all can't be combined with refspecs"));
816 + }
817 + if (inner_flags & TRANSPORT_PUSH_MIRROR) {
818 + if (argc >= 2)
819 + die(_("--mirror can't be combined with refspecs"));
820 + }
821 +
822 + refspec_clear(&rs);
823 + rs = (struct refspec) REFSPEC_INIT_PUSH;
824 +
825 + if (tags)
826 + refspec_append(&rs, "refs/tags/*");
827 + if (argc > 0)
828 + set_refspecs(argv + 1, argc - 1, r);
829 +
830 + rc = do_push(inner_flags, push_options, r);
831 + }
832 + } else {
833 + /*
834 + * Multiple remotes: spawn one "git push <remote> [<refspecs>]"
835 + * subprocess per remote, sequentially.
836 + *
837 + * Options that only make sense for a single transport connection
838 + * are rejected here.
839 + */
840 + if (flags & TRANSPORT_PUSH_ATOMIC)
841 + die(_("--atomic can only be used when pushing to one remote"));
842 +
843 + rc = push_multiple(&remote_group, push_options, flags,
844 + tags,
845 + argc > 1 ? argv + 1 : NULL,
846 + argc > 1 ? argc - 1 : 0);
847 + }
848 +
849 string_list_clear(&push_options_cmdline, 0);
850 string_list_clear(&push_options_config, 0);
851 + string_list_clear(&remote_group, 0);
852 clear_cas_option(&cas);
853 +
854 if (rc == -1)
855 usage_with_options(push_usage, options);
856 else
t/meson.build
+1
@@ -700,6 +700,7 @@ integration_tests = [
700 't5563-simple-http-auth.sh',
701 't5564-http-proxy.sh',
702 't5565-push-multiple.sh',
703 + 't5566-push-group.sh',
704 't5570-git-daemon.sh',
705 't5571-pre-push-hook.sh',
706 't5572-pull-submodule.sh',
t/t5566-push-group.sh new
+160
@@ -0,0 +1,160 @@
1 +#!/bin/sh
2 +
3 +test_description='push to remote group'
4 +
5 +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=default
6 +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
7 +
8 +. ./test-lib.sh
9 +
10 +test_expect_success 'setup' '
11 + for i in 1 2 3
12 + do
13 + git init --bare dest-$i.git &&
14 + git -C dest-$i.git symbolic-ref HEAD refs/heads/not-a-branch ||
15 + return 1
16 + done &&
17 + test_tick &&
18 + git commit --allow-empty -m "initial" &&
19 + git config set remote.remote-1.url "file://$(pwd)/dest-1.git" &&
20 + git config set remote.remote-1.fetch "+refs/heads/*:refs/remotes/remote-1/*" &&
21 + git config set remote.remote-2.url "file://$(pwd)/dest-2.git" &&
22 + git config set remote.remote-2.fetch "+refs/heads/*:refs/remotes/remote-2/*" &&
23 + git config set remote.remote-3.url "file://$(pwd)/dest-3.git" &&
24 + git config set remote.remote-3.fetch "+refs/heads/*:refs/remotes/remote-3/*" &&
25 + git config set remotes.all-remotes "remote-1 remote-2 remote-3"
26 +'
27 +
28 +test_expect_success 'push to remote group updates all members correctly' '
29 + git push all-remotes HEAD:refs/heads/main &&
30 + git rev-parse HEAD >expect &&
31 + for i in 1 2 3
32 + do
33 + git -C dest-$i.git rev-parse refs/heads/main >actual ||
34 + return 1
35 + test_cmp expect actual || return 1
36 + done
37 +'
38 +
39 +test_expect_success 'push second commit to group updates all members' '
40 + test_tick &&
41 + git commit --allow-empty -m "second" &&
42 + git push all-remotes HEAD:refs/heads/main &&
43 + git rev-parse HEAD >expect &&
44 + for i in 1 2 3
45 + do
46 + git -C dest-$i.git rev-parse refs/heads/main >actual ||
47 + return 1
48 + test_cmp expect actual || return 1
49 + done
50 +'
51 +
52 +test_expect_success 'push to single remote in group does not affect others' '
53 + test_tick &&
54 + git commit --allow-empty -m "third" &&
55 + git push remote-1 HEAD:refs/heads/main &&
56 + git -C dest-1.git rev-parse refs/heads/main >hash-after-1 &&
57 + git -C dest-2.git rev-parse refs/heads/main >hash-after-2 &&
58 + ! test_cmp hash-after-1 hash-after-2
59 +'
60 +
61 +test_expect_success 'mirror remote in group with refspec fails' '
62 + git config set remote.remote-1.mirror true &&
63 + test_must_fail git push all-remotes HEAD:refs/heads/main 2>err &&
64 + test_grep "mirror" err &&
65 + git config unset remote.remote-1.mirror
66 +'
67 +
68 +test_expect_success 'push.default=current works with group push' '
69 + git config set push.default current &&
70 + test_tick &&
71 + git commit --allow-empty -m "fifth" &&
72 + git push all-remotes &&
73 + git config unset push.default
74 +'
75 +
76 +test_expect_success '--atomic is rejected for group push' '
77 + test_must_fail git push --atomic all-remotes HEAD:refs/heads/main 2>err &&
78 + test_grep "atomic" err
79 +'
80 +
81 +test_expect_success 'push continues past rejection to remaining remotes' '
82 + for i in c1 c2 c3
83 + do
84 + git init --bare dest-$i.git || return 1
85 + done &&
86 + git config set remote.c1.url "file://$(pwd)/dest-c1.git" &&
87 + git config set remote.c2.url "file://$(pwd)/dest-c2.git" &&
88 + git config set remote.c3.url "file://$(pwd)/dest-c3.git" &&
89 + git config set remotes.continue-group "c1 c2 c3" &&
90 +
91 + test_tick &&
92 + git commit --allow-empty -m "base for continue test" &&
93 +
94 + # initial sync
95 + git push continue-group HEAD:refs/heads/main &&
96 +
97 + # advance c2 independently
98 + git clone dest-c2.git tmp-c2 &&
99 + (
100 + cd tmp-c2 &&
101 + git checkout -b main origin/main &&
102 + test_commit c2_independent &&
103 + git push origin HEAD:refs/heads/main
104 + ) &&
105 + rm -rf tmp-c2 &&
106 +
107 + test_tick &&
108 + git commit --allow-empty -m "local diverging commit" &&
109 +
110 + # push: c2 rejects, others succeed
111 + test_must_fail git push continue-group HEAD:refs/heads/main &&
112 +
113 + git rev-parse HEAD >expect &&
114 + git -C dest-c1.git rev-parse refs/heads/main >actual-c1 &&
115 + git -C dest-c3.git rev-parse refs/heads/main >actual-c3 &&
116 + test_cmp expect actual-c1 &&
117 + test_cmp expect actual-c3 &&
118 +
119 + # c2 should not have the new commit
120 + git -C dest-c2.git rev-parse refs/heads/main >actual-c2 &&
121 + ! test_cmp expect actual-c2
122 +'
123 +
124 +test_expect_success 'fatal connection error does not stop remaining remotes' '
125 + for i in f1 f2 f3
126 + do
127 + git init --bare dest-$i.git || return 1
128 + done &&
129 + git config set remote.f1.url "file://$(pwd)/dest-f1.git" &&
130 + git config set remote.f2.url "file://$(pwd)/dest-f2.git" &&
131 + git config set remote.f3.url "file://$(pwd)/dest-f3.git" &&
132 + git config set remotes.fatal-group "f1 f2 f3" &&
133 +
134 + test_tick &&
135 + git commit --allow-empty -m "base for fatal test" &&
136 +
137 + # initial sync
138 + git push fatal-group HEAD:refs/heads/main &&
139 +
140 + # break f2
141 + git config set remote.f2.url "file:///tmp/does-not-exist-$$" &&
142 +
143 + test_tick &&
144 + git commit --allow-empty -m "after fatal setup" &&
145 +
146 + # overall exit code is non-zero because f2 failed
147 + test_must_fail git push fatal-group HEAD:refs/heads/main &&
148 +
149 + git rev-parse HEAD >expect &&
150 +
151 + # f1 and f3 should both have the new commit — subprocesses are independent
152 + git -C dest-f1.git rev-parse refs/heads/main >actual-f1 &&
153 + test_cmp expect actual-f1 &&
154 + git -C dest-f3.git rev-parse refs/heads/main >actual-f3 &&
155 + test_cmp expect actual-f3 &&
156 +
157 + git config set remote.f2.url "file://$(pwd)/dest-f2.git"
158 +'
159 +
160 +test_done