From: "Pasteley Absurda via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Pasteley Absurda <ceasebeing@gmail.com>, pasteley <ceasebeing@gmail.com>
Subject: [PATCH] checkout: add remoteBranchTemplate config for DWIM branch names
Date: Sun, 21 Dec 2025 15:59:56 +0000 [thread overview]
Message-ID: <pull.2136.git.git.1766332796836.gitgitgadget@gmail.com> (raw)
From: pasteley <ceasebeing@gmail.com>
Add checkout.remoteBranchTemplate to apply a template pattern when
searching for remote branches during checkout DWIM and when creating
remote branches with push.autoSetupRemote.
Template uses printf-style placeholders (%s for branch name). For
example, with "feature/%s", checking out "foo"
searches for "origin/feature/foo" and creates local "foo"
tracking it. Pushing with autoSetupRemote creates "origin/feature/bar"
from local "bar".
Useful when remote branches use prefixes but local branches don't.
Works with git-checkout, git-worktree --guess-remote, and git-push.
Signed-off-by: pasteley <ceasebeing@gmail.com>
---
checkout: add remoteBranchTemplate config for DWIM branch names
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2136%2Fpasteley%2Fremote-branch-template-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2136/pasteley/remote-branch-template-v1
Pull-Request: https://github.com/git/git/pull/2136
Documentation/config/checkout.adoc | 10 ++
Documentation/config/push.adoc | 3 +
builtin/push.c | 23 ++++
builtin/worktree.c | 7 ++
checkout.c | 49 ++++++++-
checkout.h | 9 ++
t/t1402-check-ref-format.sh | 3 +
t/t2024-checkout-dwim.sh | 162 +++++++++++++++++++++++++++++
t/t2400-worktree-add.sh | 64 ++++++++++++
t/t5528-push-default.sh | 53 ++++++++++
10 files changed, 382 insertions(+), 1 deletion(-)
diff --git a/Documentation/config/checkout.adoc b/Documentation/config/checkout.adoc
index e35d212969..4b083d5c0c 100644
--- a/Documentation/config/checkout.adoc
+++ b/Documentation/config/checkout.adoc
@@ -22,6 +22,16 @@ commands or functionality in the future.
option in `git checkout` and `git switch`. See
linkgit:git-switch[1] and linkgit:git-checkout[1].
+`checkout.remoteBranchTemplate`::
+ Template pattern applied to remote ref names during checkout DWIM
+ and when pushing with `push.autoSetupRemote`. Uses `%s` for the
+ branch name and `%%` for a literal `%`.
++
+With `feature/%s`, `git checkout <branch>` searches for `origin/feature/<branch>`.
++
+Useful when remote branches expects specific prefixes (e.g., `feature/`, `user/`).
+Invalid templates (missing `%s`) trigger a warning and are ignored.
+
`checkout.workers`::
The number of parallel workers to use when updating the working tree.
The default is one, i.e. sequential execution. If set to a value less
diff --git a/Documentation/config/push.adoc b/Documentation/config/push.adoc
index d9112b2260..ae2031d069 100644
--- a/Documentation/config/push.adoc
+++ b/Documentation/config/push.adoc
@@ -8,6 +8,9 @@
to be set. Workflows most likely to benefit from this option are
`simple` central workflows where all branches are expected to
have the same name on the remote.
++
+When combined with `checkout.remoteBranchTemplate`, creates remote
+branches using the templated name.
`push.default`::
Defines the action `git push` should take if no refspec is
diff --git a/builtin/push.c b/builtin/push.c
index 5b6cebbb85..c43d9dd7f8 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -7,6 +7,7 @@
#include "builtin.h"
#include "advice.h"
#include "branch.h"
+#include "checkout.h"
#include "config.h"
#include "environment.h"
#include "gettext.h"
@@ -281,6 +282,28 @@ static void setup_default_push_refspecs(int *flags, struct remote *remote)
if ((*flags & TRANSPORT_PUSH_AUTO_UPSTREAM) && branch->merge_nr == 0)
*flags |= TRANSPORT_PUSH_SET_UPSTREAM;
+ /*
+ * Apply template to destination ref if configured and we're setting
+ * up upstream (either automatically or explicitly with -u).
+ */
+ if (branch->merge_nr == 0 && (*flags & (TRANSPORT_PUSH_SET_UPSTREAM | TRANSPORT_PUSH_AUTO_UPSTREAM))) {
+ const char *short_name;
+ char *templated_dst = NULL;
+
+ if (skip_prefix(dst, "refs/heads/", &short_name)) {
+ char *template_result = expand_remote_branch_template(short_name);
+ if (template_result) {
+ templated_dst = xstrfmt("refs/heads/%s", template_result);
+ free(template_result);
+ dst = templated_dst;
+ }
+ }
+
+ refspec_appendf(&rs, "%s:%s", branch->refname, dst);
+ free(templated_dst);
+ return;
+ }
+
refspec_appendf(&rs, "%s:%s", branch->refname, dst);
}
diff --git a/builtin/worktree.c b/builtin/worktree.c
index fbdaf2eb2e..9dd131d977 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -764,6 +764,13 @@ static char *dwim_branch(const char *path, char **new_branch)
if (guess_remote) {
struct object_id oid;
char *remote = unique_tracking_name(*new_branch, &oid, NULL);
+ char *templated_name = expand_remote_branch_template(*new_branch);
+
+ if (!remote && templated_name) {
+ die(_("No remote branch found for '%s' (set to '%s' by checkout.remoteBranchTemplate)"),
+ *new_branch, templated_name);
+ }
+ free(templated_name);
return remote;
}
return NULL;
diff --git a/checkout.c b/checkout.c
index 1588b116ee..daa38b74b7 100644
--- a/checkout.c
+++ b/checkout.c
@@ -8,6 +8,7 @@
#include "checkout.h"
#include "config.h"
#include "strbuf.h"
+#include "gettext.h"
struct tracking_name_data {
/* const */ char *src_ref;
@@ -52,14 +53,23 @@ char *unique_tracking_name(const char *name, struct object_id *oid,
{
struct tracking_name_data cb_data = TRACKING_NAME_DATA_INIT;
const char *default_remote = NULL;
+ char *templated_name = NULL;
+ const char *search_name;
+
if (!repo_config_get_string_tmp(the_repository, "checkout.defaultremote", &default_remote))
cb_data.default_remote = default_remote;
- cb_data.src_ref = xstrfmt("refs/heads/%s", name);
+
+ templated_name = expand_remote_branch_template(name);
+ search_name = templated_name ? templated_name : name;
+
+ cb_data.src_ref = xstrfmt("refs/heads/%s", search_name);
cb_data.dst_oid = oid;
for_each_remote(check_tracking_name, &cb_data);
if (dwim_remotes_matched)
*dwim_remotes_matched = cb_data.num_matches;
free(cb_data.src_ref);
+ free(templated_name);
+
if (cb_data.num_matches == 1) {
free(cb_data.default_dst_ref);
free(cb_data.default_dst_oid);
@@ -73,3 +83,40 @@ char *unique_tracking_name(const char *name, struct object_id *oid,
}
return NULL;
}
+
+char *expand_remote_branch_template(const char *name)
+{
+ const char *tpl = NULL;
+ const char *fmt;
+ struct strbuf out = STRBUF_INIT;
+ int saw_placeholder = 0;
+
+ if (repo_config_get_string_tmp(the_repository,
+ "checkout.remoteBranchTemplate",
+ &tpl))
+ return NULL;
+
+ fmt = tpl;
+ while (strbuf_expand_step(&out, &fmt)) {
+ if (skip_prefix(fmt, "%", &fmt)) {
+ strbuf_addch(&out, '%');
+ } else if (skip_prefix(fmt, "s", &fmt)) {
+ strbuf_addstr(&out, name);
+ saw_placeholder = 1;
+ } else {
+ /*
+ * Unknown placeholder: keep '%' literal to avoid
+ * surprising behavior (e.g., "%x" stays "%x").
+ */
+ strbuf_addch(&out, '%');
+ }
+ }
+
+ if (!saw_placeholder) {
+ strbuf_release(&out);
+ warning("%s", _("checkout.remoteBranchTemplate missing '%%s' placeholder; ignoring"));
+ return NULL;
+ }
+
+ return strbuf_detach(&out, NULL);
+}
diff --git a/checkout.h b/checkout.h
index 55920e7aeb..2549e8de30 100644
--- a/checkout.h
+++ b/checkout.h
@@ -3,6 +3,15 @@
#include "hash.h"
+/*
+ * If checkout.remoteBranchTemplate is set, expand it using printf-style
+ * substitution:
+ * %s -> the branch name
+ * %% -> a literal %
+ * Returns a newly allocated string, or NULL if unset/invalid.
+ */
+char *expand_remote_branch_template(const char *name);
+
/*
* Check if the branch name uniquely matches a branch name on a remote
* tracking branch. Return the name of the remote if such a branch
diff --git a/t/t1402-check-ref-format.sh b/t/t1402-check-ref-format.sh
index cabc516ae9..f174bbf2cc 100755
--- a/t/t1402-check-ref-format.sh
+++ b/t/t1402-check-ref-format.sh
@@ -58,6 +58,9 @@ invalid_ref 'foo.lock/bar'
invalid_ref 'foo.lock///bar'
valid_ref 'heads/foo@bar'
invalid_ref 'heads/v@{ation'
+valid_ref 'heads/foo%bar'
+valid_ref 'heads/foo%s'
+valid_ref 'heads/100%special'
invalid_ref 'heads/foo\bar'
invalid_ref "$(printf 'heads/foo\t')"
invalid_ref "$(printf 'heads/foo\177')"
diff --git a/t/t2024-checkout-dwim.sh b/t/t2024-checkout-dwim.sh
index a3b1449ef1..f409bc07c1 100755
--- a/t/t2024-checkout-dwim.sh
+++ b/t/t2024-checkout-dwim.sh
@@ -347,4 +347,166 @@ test_expect_success 'disambiguate dwim branch and checkout path (2)' '
grep bar dwim-arg2
'
+test_expect_success 'setup for remoteBranchTemplate tests' '
+ (
+ cd repo_a &&
+ git checkout -b feature/newbar &&
+ test_commit a_feature_newbar &&
+ git checkout -b "100%special" &&
+ test_commit a_special &&
+ git checkout -b template_test &&
+ test_commit a_template_test
+ ) &&
+ git fetch repo_a
+'
+
+test_expect_success 'checkout.remoteBranchTemplate with prefix' '
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D newbar &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+
+ git checkout newbar &&
+ status_uno_is_clean &&
+ test_branch newbar &&
+ test_cmp_rev remotes/repo_a/feature/newbar HEAD &&
+ test_branch_upstream newbar repo_a feature/newbar
+'
+
+test_expect_success 'checkout.remoteBranchTemplate handles literal %%' '
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D special &&
+ test_config checkout.remoteBranchTemplate "100%%%s" &&
+
+ git checkout special &&
+ status_uno_is_clean &&
+ test_branch special &&
+ test_cmp_rev remotes/repo_a/100%special HEAD &&
+ test_branch_upstream special repo_a 100%special
+'
+
+test_expect_success 'checkout.remoteBranchTemplate without %s is ignored with warning' '
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D template_test &&
+ test_config checkout.remoteBranchTemplate "fixed-name" &&
+
+ git checkout template_test 2>stderr &&
+ status_uno_is_clean &&
+ test_branch template_test &&
+ test_cmp_rev remotes/repo_a/template_test HEAD &&
+ test_grep "missing.*%s.*placeholder" stderr
+'
+
+test_expect_success 'checkout.remoteBranchTemplate with multiple %s placeholders' '
+ (
+ cd repo_a &&
+ git checkout -b user/multi/multi &&
+ test_commit a_multi_placeholder
+ ) &&
+ git fetch repo_a &&
+
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D multi &&
+ test_config checkout.remoteBranchTemplate "user/%s/%s" &&
+
+ git checkout multi &&
+ status_uno_is_clean &&
+ test_branch multi &&
+ test_cmp_rev remotes/repo_a/user/multi/multi HEAD &&
+ test_branch_upstream multi repo_a user/multi/multi
+'
+
+test_expect_success 'checkout.remoteBranchTemplate + defaultRemote resolves ambiguity' '
+ (
+ cd repo_a &&
+ git checkout -b team/shared &&
+ test_commit a_team_shared
+ ) &&
+ (
+ cd repo_b &&
+ git checkout -b team/shared &&
+ test_commit b_team_shared
+ ) &&
+ git fetch --multiple repo_a repo_b &&
+
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D shared &&
+ test_config checkout.remoteBranchTemplate "team/%s" &&
+ test_config checkout.defaultRemote repo_b &&
+
+ git checkout shared &&
+ status_uno_is_clean &&
+ test_branch shared &&
+ test_cmp_rev remotes/other_b/team/shared HEAD &&
+ test_branch_upstream shared repo_b team/shared
+'
+
+test_expect_success 'checkout.remoteBranchTemplate with no matches fails' '
+ git checkout -B main &&
+ test_might_fail git branch -D nonexistent &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+
+ test_must_fail git checkout nonexistent &&
+ test_must_fail git rev-parse --verify refs/heads/nonexistent &&
+ test_branch main
+'
+
+test_expect_success 'checkout.remoteBranchTemplate still fails on ambiguity' '
+ git checkout -B main &&
+ test_might_fail git branch -D shared &&
+ test_config checkout.remoteBranchTemplate "team/%s" &&
+
+ test_must_fail git checkout shared 2>stderr &&
+ test_grep "matched multiple.*remote tracking branches" stderr &&
+ test_must_fail git rev-parse --verify refs/heads/shared &&
+ test_branch main
+'
+
+test_expect_success 'checkout.remoteBranchTemplate respects --no-guess' '
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D newbar &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+
+ test_must_fail git checkout --no-guess newbar &&
+ test_must_fail git rev-parse --verify refs/heads/newbar &&
+ test_branch main
+'
+
+test_expect_success 'checkout.remoteBranchTemplate respects checkout.guess = false' '
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D newbar &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+ test_config checkout.guess false &&
+
+ test_must_fail git checkout newbar &&
+ test_must_fail git rev-parse --verify refs/heads/newbar &&
+ test_branch main
+'
+
+test_expect_success 'checkout.remoteBranchTemplate with unknown placeholder kept literal' '
+ (
+ cd repo_a &&
+ git checkout -b "%d/literal" &&
+ test_commit a_literal_placeholder
+ ) &&
+ git fetch repo_a &&
+
+ git checkout -B main &&
+ git reset --hard &&
+ test_might_fail git branch -D literal &&
+ test_config checkout.remoteBranchTemplate "%d/%s" &&
+
+ git checkout literal &&
+ status_uno_is_clean &&
+ test_branch literal &&
+ test_cmp_rev remotes/repo_a/%d/literal HEAD &&
+ test_branch_upstream literal repo_a %d/literal
+'
+
test_done
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 023e1301c8..8b890d4b1e 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -730,6 +730,70 @@ test_expect_success 'git worktree --no-guess-remote option overrides config' '
)
'
+test_expect_success 'git worktree add --guess-remote with remoteBranchTemplate' '
+ test_when_finished rm -rf repo_a repo_b bar &&
+ git init repo_a &&
+ (
+ cd repo_a &&
+ test_commit repo_a_main &&
+ git checkout -b feature/bar &&
+ test_commit feature_bar
+ ) &&
+ git init repo_b &&
+ (
+ cd repo_b &&
+ test_commit repo_b_main &&
+ git remote add repo_a ../repo_a &&
+ git config remote.repo_a.fetch "refs/heads/*:refs/remotes/repo_a/*" &&
+ git fetch --all &&
+ git config checkout.remoteBranchTemplate "feature/%s" &&
+ git worktree add --guess-remote ../bar
+ ) &&
+ (
+ cd bar &&
+ test_branch_upstream bar repo_a feature/bar &&
+ test_cmp_rev refs/remotes/repo_a/feature/bar refs/heads/bar
+ )
+'
+
+test_expect_success 'git worktree add --guess-remote with remoteBranchTemplate handles %%' '
+ test_when_finished rm -rf repo_a repo_b special &&
+ git init repo_a &&
+ (
+ cd repo_a &&
+ test_commit repo_a_main &&
+ git checkout -b "100%special" &&
+ test_commit percent_special
+ ) &&
+ git init repo_b &&
+ (
+ cd repo_b &&
+ test_commit repo_b_main &&
+ git remote add repo_a ../repo_a &&
+ git config remote.repo_a.fetch "refs/heads/*:refs/remotes/repo_a/*" &&
+ git fetch --all &&
+ git config checkout.remoteBranchTemplate "100%%%s" &&
+ git worktree add --guess-remote ../special
+ ) &&
+ (
+ cd special &&
+ test_branch_upstream special repo_a 100%special &&
+ test_cmp_rev refs/remotes/repo_a/100%special refs/heads/special
+ )
+'
+
+test_expect_success 'git worktree add --guess-remote with remoteBranchTemplate and no match fails' '
+ test_when_finished rm -rf repo_a repo_b nomatch &&
+ setup_remote_repo repo_a repo_b &&
+ (
+ cd repo_b &&
+ git config checkout.remoteBranchTemplate "feature/%s" &&
+ test_must_fail git worktree add --guess-remote ../nomatch 2>err &&
+ test_grep "No remote branch found for" err &&
+ test_grep "feature/nomatch" err
+ )
+'
+
test_dwim_orphan () {
local info_text="No possible source branch, inferring '--orphan'" &&
local fetch_error_text="fatal: No local or remote refs exist despite at least one remote" &&
diff --git a/t/t5528-push-default.sh b/t/t5528-push-default.sh
index 2bd8759a68..e0d1c3cf02 100755
--- a/t/t5528-push-default.sh
+++ b/t/t5528-push-default.sh
@@ -294,4 +294,57 @@ test_expect_success 'default triangular behavior acts like "current"' '
test_push_success "" main repo2
'
+test_expect_success 'push.autoSetupRemote + remoteBranchTemplate with push.default=simple' '
+ git checkout -b template-simple &&
+ test_config push.autoSetupRemote true &&
+ test_config push.default simple &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+ test_config branch.template-simple.remote parent1 &&
+ test_commit simple-commit &&
+ git push &&
+ git --git-dir=repo1 show-ref refs/heads/feature/template-simple &&
+ test_cmp_rev HEAD parent1/feature/template-simple &&
+ echo "refs/heads/feature/template-simple" >expect &&
+ git config branch.template-simple.merge >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success 'push.autoSetupRemote + remoteBranchTemplate with push.default=upstream' '
+ git checkout -b template-upstream &&
+ test_config push.autoSetupRemote true &&
+ test_config push.default upstream &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+ test_config branch.template-upstream.remote parent1 &&
+ test_commit upstream-commit &&
+ git push &&
+ git --git-dir=repo1 show-ref refs/heads/feature/template-upstream &&
+ test_cmp_rev HEAD parent1/feature/template-upstream &&
+ echo "refs/heads/feature/template-upstream" >expect &&
+ git config branch.template-upstream.merge >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success 'push.autoSetupRemote + remoteBranchTemplate with push.default=current' '
+ git checkout -b template-current &&
+ test_config push.autoSetupRemote true &&
+ test_config push.default current &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+ test_commit current-commit &&
+ git push parent1 &&
+ git --git-dir=repo1 show-ref refs/heads/feature/template-current &&
+ test_cmp_rev HEAD parent1/feature/template-current &&
+ echo "refs/heads/feature/template-current" >expect &&
+ git config branch.template-current.merge >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success 'remoteBranchTemplate ignored with push.default=nothing' '
+ git checkout -b template-nothing &&
+ test_config push.default nothing &&
+ test_config checkout.remoteBranchTemplate "feature/%s" &&
+ test_commit nothing-commit &&
+ test_must_fail git push 2>err &&
+ test_grep "No configured push destination" err
+'
+
test_done
base-commit: c4a0c8845e2426375ad257b6c221a3a7d92ecfda
--
gitgitgadget
next reply other threads:[~2025-12-21 16:00 UTC|newest]
Thread overview: 5+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-12-21 15:59 Pasteley Absurda via GitGitGadget [this message]
2025-12-22 4:40 ` [PATCH] checkout: add remoteBranchTemplate config for DWIM branch names Junio C Hamano
2025-12-22 18:27 ` pasteley
2025-12-23 0:51 ` Junio C Hamano
2025-12-23 2:39 ` pasteley
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2136.git.git.1766332796836.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=ceasebeing@gmail.com \
--cc=git@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).