remote: find tracking branches for URL push destinations

Git accepts a repository URL as branch.<name>.pushRemote and can push to it. This branch setting takes precedence over remote.pushDefault. A branch can be configured with a URL-valued pushRemote before any push occurs. If the remotes are later rearranged with "git remote rename" and "git remote add", the newly added remote may use that URL. The URL value is unaffected by the rename and continues to take precedence over remote.pushDefault. The URL and the remote then point to the same repository, but Git does not connect them for tracking. Pushing works, but @{push} cannot identify the remote's tracking branch. As a result, "git status" cannot show the push branch, and an up-to-date push can leave its tracking information stale. When exactly one configured remote would push to the same URL, use that remote for push tracking. Continue to push to the URL so the configured remote's push settings do not change existing behavior. Keep the current behavior when no remote matches or multiple remotes match. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Jul 22, 2026 at 18:08 UTC 93775e35b7ed7983a8aaa33a566f45a8a1152718
6 files changed +198 -2
Documentation/config/branch.adoc
+1
@@ -55,6 +55,7 @@ This option defaults to `never`.
55 repository), you would want to set `remote.pushDefault` to
56 specify the remote to push to for all branches, and use this
57 option to override it for a specific branch.
58 + The value may be the name of a configured remote or a repository URL.
59
60 `branch.<name>.merge`::
61 Defines, together with `branch.<name>.remote`, the upstream branch
Documentation/revisions.adoc
+3
@@ -127,6 +127,9 @@ some output processing may assume ref names in UTF-8.
127 `git push` were run while `branchname` was checked out (or the current
128 `HEAD` if no branchname is specified). Like for '@\{upstream\}', we report
129 the remote-tracking branch that corresponds to that branch at the remote.
130 + If the push destination is a URL and exactly one configured remote uses
131 + that URL for pushing, '@\{push}' reports that remote's remote-tracking
132 + branch.
133 +
134 Here's an example to make it more clear:
135 +
remote.c
+44 -1
@@ -954,6 +954,17 @@ struct strvec *push_url_of_remote(struct remote *remote)
954 return remote->pushurl.nr ? &remote->pushurl : &remote->url;
955 }
956
957 +static bool remote_has_push_url(struct remote *remote, const char *url)
958 +{
959 + const struct strvec *push_urls = push_url_of_remote(remote);
960 +
961 + for (size_t i = 0; i < push_urls->nr; i++) {
962 + if (!strcmp(push_urls->v[i], url))
963 + return true;
964 + }
965 + return false;
966 +}
967 +
968 void ref_push_report_free(struct ref_push_report *report)
969 {
970 while (report) {
@@ -1887,13 +1898,45 @@ const char *branch_get_upstream(struct branch *branch, struct strbuf *err)
1898 return branch->merge[0]->dst;
1899 }
1900
1890 -static char *tracking_for_push_dest(struct repository *repo UNUSED,
1901 +struct remote *repo_remote_for_push_tracking(struct repository *repo,
1902 + struct remote *remote)
1903 +{
1904 + const struct strvec *push_urls;
1905 + struct remote *first_match = NULL;
1906 + struct remote_state *remote_state = repo->remote_state;
1907 + const char *check_url;
1908 +
1909 + if (remote->origin != REMOTE_UNCONFIGURED)
1910 + return remote;
1911 +
1912 + push_urls = push_url_of_remote(remote);
1913 + if (push_urls->nr != 1)
1914 + return remote;
1915 + check_url = push_urls->v[0];
1916 +
1917 + for (int i = 0; i < remote_state->remotes_nr; i++) {
1918 + struct remote *candidate = remote_state->remotes[i];
1919 +
1920 + if (!candidate || candidate == remote ||
1921 + !remote_is_configured(candidate, 0) ||
1922 + !remote_has_push_url(candidate, check_url))
1923 + continue;
1924 + if (first_match)
1925 + return remote;
1926 + first_match = candidate;
1927 + }
1928 +
1929 + return first_match ? first_match : remote;
1930 +}
1931 +
1932 +static char *tracking_for_push_dest(struct repository *repo,
1933 struct remote *remote,
1934 const char *refname,
1935 struct strbuf *err)
1936 {
1937 char *ret;
1938
1939 + remote = repo_remote_for_push_tracking(repo, remote);
1940 ret = apply_refspecs(&remote->fetch, refname);
1941 if (!ret)
1942 return error_buf(err,
remote.h
+2
@@ -345,6 +345,8 @@ char *remote_ref_for_branch(struct branch *branch, int for_push);
345
346 const char *repo_default_remote(struct repository *repo);
347 const char *repo_remote_from_url(struct repository *repo, const char *url);
348 +struct remote *repo_remote_for_push_tracking(struct repository *repo,
349 + struct remote *remote);
350
351 /* returns true if the given branch has merge configuration given. */
352 int branch_has_merge_config(struct branch *branch);
t/t5505-remote.sh
+144
@@ -24,6 +24,28 @@ setup_repository () {
24 )
25 }
26
27 +setup_url_pushremote () {
28 + rm -rf fork.git client &&
29 + git clone --bare one fork.git &&
30 + git clone one client &&
31 + fork_url="file://$TRASH_DIRECTORY/fork.git" &&
32 + (
33 + cd client &&
34 + git checkout -b topic --track origin/main &&
35 + git commit --allow-empty -m topic-change &&
36 + git config push.default current &&
37 + git config status.compareBranches "@{upstream} @{push}" &&
38 + git config branch.topic.pushRemote "$fork_url" &&
39 + git push
40 + )
41 +}
42 +
43 +check_status () {
44 + git -C client status >actual &&
45 + cat >expected &&
46 + test_cmp expected actual
47 +}
48 +
49 tokens_match () {
50 echo "$1" | tr ' ' '\012' | sort | sed -e '/^$/d' >expect &&
51 echo "$2" | tr ' ' '\012' | sort | sed -e '/^$/d' >actual &&
@@ -1018,6 +1040,128 @@ test_expect_success 'rename a remote renames repo remote.pushDefault but keeps g
1040 )
1041 '
1042
1043 +test_expect_success 'URL-valued pushRemote without matching remote is not trackable' '
1044 + setup_url_pushremote &&
1045 +
1046 + check_status <<-EOF
1047 + On branch topic
1048 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.
1049 + (use "git push" to publish your local commits)
1050 +
1051 + nothing to commit, working tree clean
1052 + EOF
1053 +'
1054 +
1055 +test_expect_success 'adding matching remote makes URL-valued pushRemote trackable' '
1056 + setup_url_pushremote &&
1057 +
1058 + (
1059 + cd client &&
1060 + git remote rename origin upstream &&
1061 + git remote add -f origin "$fork_url"
1062 + ) &&
1063 +
1064 + check_status <<-EOF
1065 + On branch topic
1066 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
1067 +
1068 + Your branch is up to date with ${SQ}origin/topic${SQ}.
1069 +
1070 + nothing to commit, working tree clean
1071 + EOF
1072 +'
1073 +
1074 +test_expect_success 'configured pushurl makes URL-valued pushRemote trackable' '
1075 + setup_url_pushremote &&
1076 +
1077 + (
1078 + cd client &&
1079 + git remote rename origin upstream &&
1080 + git remote add -f origin ../fork.git &&
1081 + git remote set-url --push origin "$fork_url"
1082 + ) &&
1083 +
1084 + check_status <<-EOF
1085 + On branch topic
1086 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
1087 +
1088 + Your branch is up to date with ${SQ}origin/topic${SQ}.
1089 +
1090 + nothing to commit, working tree clean
1091 + EOF
1092 +'
1093 +
1094 +test_expect_success 'pushInsteadOf URL pushRemote is trackable' '
1095 + setup_url_pushremote &&
1096 + (
1097 + cd client &&
1098 + git remote rename origin upstream &&
1099 + git remote add -f origin "$fork_url" &&
1100 + git config "url.$fork_url.pushInsteadOf" fork: &&
1101 + git config branch.topic.pushRemote fork:
1102 + ) &&
1103 +
1104 + check_status <<-EOF
1105 + On branch topic
1106 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
1107 +
1108 + Your branch is up to date with ${SQ}origin/topic${SQ}.
1109 +
1110 + nothing to commit, working tree clean
1111 + EOF
1112 +'
1113 +
1114 +test_expect_success 'up-to-date URL push refreshes stale tracking branch' '
1115 + setup_url_pushremote &&
1116 + (
1117 + cd client &&
1118 + git remote rename origin upstream &&
1119 + git remote add -f origin "$fork_url" &&
1120 + git commit --allow-empty -m another-topic-change &&
1121 + git -C ../fork.git fetch ../client topic:topic
1122 + ) &&
1123 +
1124 + check_status <<-EOF &&
1125 + On branch topic
1126 + Your branch is ahead of ${SQ}upstream/main${SQ} by 2 commits.
1127 +
1128 + Your branch is ahead of ${SQ}origin/topic${SQ} by 1 commit.
1129 + (use "git push" to publish your local commits)
1130 +
1131 + nothing to commit, working tree clean
1132 + EOF
1133 +
1134 + git -C client push >actual 2>&1 &&
1135 + test_grep "Everything up-to-date" actual &&
1136 +
1137 + check_status <<-EOF
1138 + On branch topic
1139 + Your branch is ahead of ${SQ}upstream/main${SQ} by 2 commits.
1140 +
1141 + Your branch is up to date with ${SQ}origin/topic${SQ}.
1142 +
1143 + nothing to commit, working tree clean
1144 + EOF
1145 +'
1146 +
1147 +test_expect_success 'duplicate remote URL leaves URL-valued pushRemote ambiguous' '
1148 + setup_url_pushremote &&
1149 + (
1150 + cd client &&
1151 + git remote rename origin upstream &&
1152 + git remote add -f origin "$fork_url" &&
1153 + git remote add duplicate "$fork_url"
1154 + ) &&
1155 +
1156 + check_status <<-EOF
1157 + On branch topic
1158 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
1159 + (use "git push" to publish your local commits)
1160 +
1161 + nothing to commit, working tree clean
1162 + EOF
1163 +'
1164 +
1165 test_expect_success 'rename handles remote without fetch refspec' '
1166 git clone --bare one no-refspec.git &&
1167 # confirm assumption that bare clone does not create refspec
transport.c
+4 -1
@@ -1553,8 +1553,11 @@ int transport_push(struct repository *r,
1553 if (!(flags & (TRANSPORT_PUSH_DRY_RUN |
1554 TRANSPORT_RECURSE_SUBMODULES_ONLY))) {
1555 struct ref *ref;
1556 + struct remote *tracking_remote = repo_remote_for_push_tracking(
1557 + r, transport->remote);
1558 +
1559 for (ref = remote_refs; ref; ref = ref->next)
1557 - transport_update_tracking_ref(transport->remote, ref, verbose);
1560 + transport_update_tracking_ref(tracking_remote, ref, verbose);
1561 }
1562
1563 if (porcelain && !push_ret)