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