From: Usman Akinyemi Date: Wed, 25 Mar 2026 19:09:06 +0000 (+0530) Subject: push: support pushing to a remote group X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=aac9bf095f1a3f1e7cdc74c6d4e537f671b93a5f;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..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 `. `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,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.` +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. + +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 7100ffba5d..ed292c48fc 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -552,12 +552,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 +621,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 +669,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 " + * 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 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..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