From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from mail-pj1-f52.google.com (mail-pj1-f52.google.com [209.85.216.52]) (using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id 3299E372B3D for ; Sun, 3 May 2026 15:34:23 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=209.85.216.52 ARC-Seal:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1777822465; cv=none; b=uUTYIC807Squ9SAkSEYItggxyDDVB+2VunQ/jF7abQcJphM7SYVAXo15DRWigBvp1sOfdB0LDPmPZZJ0INrFMDyjjkb6Gx6Nbo6v+l9JiULOvsmrW5b2m2kw1N3s4Jql/q6dGfscvt0eyIsTLdPOG78vNmoxHJlhWF7NnCJa4og= ARC-Message-Signature:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1777822465; c=relaxed/simple; bh=N0OdUsWsH0k+rXsbYBDeWHTr6pPJXiSXiyt2U8ycZ/Y=; h=From:To:Cc:Subject:Date:Message-ID:In-Reply-To:References: MIME-Version:Content-Type; b=Eqf7PEUo1vK5KmGKFpRs1ttMwbadOH846jiqPAJI5vuo8isYatZ9FGtmSjVbRiCaynhzyGyruyi480QTEnSLis6bKt3NcAq8OVTgnkF41W73fPoMhNfigpQYmETDqOXDe4V7sqptjiQA0Q/yheeFYAzNpOnGGR6HY5ag2MVSws4= ARC-Authentication-Results:i=1; smtp.subspace.kernel.org; dmarc=pass (p=none dis=none) header.from=gmail.com; spf=pass smtp.mailfrom=gmail.com; dkim=pass (2048-bit key) header.d=gmail.com header.i=@gmail.com header.b=F/CdYVSa; arc=none smtp.client-ip=209.85.216.52 Authentication-Results: smtp.subspace.kernel.org; dmarc=pass (p=none dis=none) header.from=gmail.com Authentication-Results: smtp.subspace.kernel.org; spf=pass smtp.mailfrom=gmail.com Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=gmail.com header.i=@gmail.com header.b="F/CdYVSa" Received: by mail-pj1-f52.google.com with SMTP id 98e67ed59e1d1-36505450d0dso1645191a91.1 for ; Sun, 03 May 2026 08:34:23 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1777822462; x=1778427262; darn=vger.kernel.org; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=O1KcxDnCvNNtEHoqWejl6MRKaNBod1OFyDCzKFO5ZeQ=; b=F/CdYVSaSf4O49QYepEZjoFnabt86RzQQvQVtWfWTHHuSwdBQxVGUdJsuF9O2lmjet jV4K4SkZ3tY3KTgoTMUO0RveJ4A4qBaEKCBJTfz5oD0WYngLfIsUstdZdqasgpwge1op 7yfoP5JrKD5LsmUYGZZJMxnwfArMOCl+QIUpSEBg7tu3E3nTV5Ucfbh1SwbAm/BVSdb6 IQ0pYtVepJxdoB1Hd/VAiNSmzU2hIhk00SKD1cqnrlM6eDIYeaWRqY90VEXYguGD555N HqEBICvwI8150je8pXM0r8Dom4dcLYY4ObATlmWm//7d4aXuiEyduU35RILamN2PuEdX 0MZg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1777822462; x=1778427262; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to; bh=O1KcxDnCvNNtEHoqWejl6MRKaNBod1OFyDCzKFO5ZeQ=; b=A7Ckrm5xEeeliLrJbwT+wu9EApsg9B/Tegviu+l4592ksJPCaHvxL8nm+0cbUil/U2 bstkOAHAi8aLtOzEVkfRodSbYmECZIKpCH5Vfh0A1mgNVANsAA1qQe4CnqMcNE/+y7vB A8rltehShqgB5/FXySUGo4S/1QzpQbAxFQuT9lUuZtci4ztvVjlR0VNRyMwgKWGLQnkY 077YYXd4C3D/q7qsx+To5RVt4ZeWPWq8pTCaedaoPLB8aGBxltX/8LN6wN0Rr6obM++s dZ72BJbVQK9L1ugeAScawSz5dsEfNDi6g7nyjlkhUvhQ8ZyetXWrj7w1yocKvZsIHjLK P1BA== X-Forwarded-Encrypted: i=1; AFNElJ9CY5i5NexuN6v6uyedIJQNOOwoLDzK5qg7M0ZL60ucJkbDXXkqXOiXPRr/tt+NcAv5M4Y=@vger.kernel.org X-Gm-Message-State: AOJu0YzJAU9EQq7vn36fdAtDKl4nm6Rw6FUFcxzNSF3vb4coiyz3XwiZ aEyOpjP00mJbpfWFeHWLfxMO+hgJ66hoo7PWbw7ZOvq07QzyVqGKbvc0 X-Gm-Gg: AeBDietMjdYYCRBIWDgAMYTBalMl2MY7D4nYysI6ckyxGyTuzJm1vXm0LOOgLv8QSXO w5qltJk7vsB28J5E4JofWSXFyLJHwnYSdCV+s0f04Eof6SUhrAsnezjmOzbLuT5Wlx5TusW6u1i IT9CWfN3A4gzg+MobWGC0Va2PRxXL1ZudXlun1m1/nb/8cljYhF/BmG8vi8UcsIrHMXxbPya1On UmOvC7LjodNuzNSGCrxIVPv7eGivX0K9J6z7mtCEioAzvJRcMXSXquxsTIa/nmYsxxy5xZy594i orruZzEWIe5Xd2GZmD0dNlbQWY1t0so26xUiw46+XAAzwvnRie5uQOUcXXMQxcHPE6cDewkBc3u AWf7BtZym/z3WJgHNMIb/ALoNQ9MLvuucU/UYhWIwZtFXb9bxF9XqX7wPLrjs9e/gLMWLH/rzsb hqMZ64GbOiUlwGfqxOFSgCg5AGzC2iKG60WBs3LVfXFwd1v11VKs7XcLOeSEyOzBvminN0tc1mO skiIBycrRM= X-Received: by 2002:a17:902:778a:b0:2b2:81aa:f6ba with SMTP id d9443c01a7336-2b9f25e522fmr44201955ad.26.1777822462282; Sun, 03 May 2026 08:34:22 -0700 (PDT) Received: from archlinux ([182.75.25.162]) by smtp.gmail.com with ESMTPSA id d9443c01a7336-2b9cae0f0acsm74618375ad.38.2026.05.03.08.34.17 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Sun, 03 May 2026 08:34:21 -0700 (PDT) From: Usman Akinyemi 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 v5 3/3] push: support pushing to a remote group Date: Sun, 3 May 2026 21:04:02 +0530 Message-ID: <20260503153402.1333220-4-usmanakinyemi202@gmail.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260503153402.1333220-1-usmanakinyemi202@gmail.com> References: <20260427140530.856125-1-usmanakinyemi202@gmail.com> <20260503153402.1333220-1-usmanakinyemi202@gmail.com> Precedence: bulk X-Mailing-List: git@vger.kernel.org List-Id: List-Subscribe: List-Unsubscribe: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- Documentation/git-push.adoc | 80 ++++++++++-- builtin/push.c | 251 +++++++++++++++++++++++++++++++----- t/meson.build | 1 + t/t5566-push-group.sh | 160 +++++++++++++++++++++++ 4 files changed, 451 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..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..6021b71d66 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,123 @@ 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 result = 0; + size_t i; + 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 +670,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 +739,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 +787,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 7528e5cda5..bd090627e9 100644 --- a/t/meson.build +++ b/t/meson.build @@ -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', 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 -- 2.53.0