From 0ff8ebc99fe700a142a3f00c0188f70f3f1dd9cd Mon Sep 17 00:00:00 2001 From: pasteley Date: Sun, 21 Dec 2025 03:35:53 +0100 Subject: [PATCH] checkout: add remoteBranchTemplate config for DWIM branch names 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 --- 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 e35d21296978fe..4b083d5c0c8d64 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 ` searches for `origin/feature/`. ++ +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 d9112b22609b51..ae2031d0692e1c 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 5b6cebbb856cfc..c43d9dd7f8d9b9 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 fbdaf2eb2eb85c..9dd131d97758c9 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 1588b116eedf06..daa38b74b7228d 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 55920e7aeb243d..2549e8de30e7b5 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 cabc516ae9a4fa..f174bbf2ccc042 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 a3b1449ef11667..f409bc07c16359 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 023e1301c8e68e..8b890d4b1edf66 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 2bd8759a683ba0..e0d1c3cf02f7ea 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