Merge branch 'ua/push-remote-group' (early part) into jch

* 'ua/push-remote-group' (early part):
  push: support pushing to a remote group
  remote: move remote group resolution to remote.c
This commit is contained in:
Junio C Hamano
2026-04-09 11:22:16 -07:00
7 changed files with 355 additions and 83 deletions

View File

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

View File

@@ -2138,48 +2138,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)
{

View File

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

View File

@@ -2114,6 +2114,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)

View File

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

View File

@@ -704,6 +704,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',

150
t/t5566-push-group.sh Executable file
View File

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