All of lore.kernel.org
 help / color / mirror / Atom feed
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

             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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.