Skip to content

Commit 908a6c9

Browse files
committed
remote: add url=/token= to fetch/push/pull, plus dest_ref for fetch
Remote.fetch(), Remote.push(), and Remote.pull() could previously only operate against the remote's configured name/URL, forcing anyone who wanted to use an alternate URL (e.g. with an embedded credential) to first rewrite the remote's permanent config via set_url() -- persisting a token to .git/config as a side effect just to do a single operation. All three shell out to the underlying git transport command with the remote's name hardcoded as the target; git remote update/rename/set-url/prune etc. are not affected since those are config-only commands that don't accept an arbitrary URL to begin with. Add to Remote.fetch(), Remote.push(), and Remote.pull(): - url: use this URL for this call only. The remote's stored config is never read or written for the transport target when given; it takes precedence over the remote's name/configured url. - token: optional credential embedded into url's authority component for this call only (http/https only). Never written to config or logged, and scrubbed from GitCommandError text if the call fails, so a failure can't leak the token via an exception/log message. Additionally, Remote.fetch() gets dest_ref (fetch-only -- push writes directly to the named destination ref regardless of url=, and pull's merge target is the checked-out branch, so neither has fetch's FETCH_HEAD-only footgun): a *bare* refspec entry (no ':dst') only updates FETCH_HEAD when fetching from an anonymous url, because unlike a named/configured remote, git has no remote.<name>.fetch pattern to complete a destination from. dest_ref lets a caller ask for a normal tracking ref instead: * True -> refs/remotes/<this-remote-name>/<branch> * '...{branch}...' -> templated destination, applied per-entry when refspec is a list * plain string -> used verbatim (single bare entry only) Entries that already contain ':' are left untouched. Raises ValueError if given without url=, or if a plain-string dest_ref is combined with more than one bare refspec entry; raises TypeError for any other dest_ref type. Existing calls (no url=/token=/dest_ref=) are unaffected on all three methods; behavior and the 'no refspec configured' assertion are unchanged for that path on fetch/pull. Adds test coverage for: one-off URL fetch/push/pull (each confirming the configured remote url/config is untouched afterwards and, for push/pull, that the operation actually took effect against the alternate target), skipping the refspec-assertion when url= is given, token= rejecting non-http(s) urls on all three methods, token redaction in the raised GitCommandError on failure for all three, dest_ref=True creating a real tracking ref, a '{branch}' template applied across a list of refspecs, a plain-string dest_ref, explicit 'src:dst' refspecs being left untouched by dest_ref, and the ValueError/TypeError validation paths.
1 parent e227e01 commit 908a6c9

2 files changed

Lines changed: 498 additions & 20 deletions

File tree

git/remote.py

Lines changed: 201 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import contextlib
1111
import logging
1212
import re
13+
from urllib.parse import urlsplit, urlunsplit
1314

1415
from git.cmd import Git, handle_process_output
1516
from git.compat import defenc, force_text
@@ -1008,6 +1009,9 @@ def fetch(
10081009
kill_after_timeout: Union[None, float] = None,
10091010
allow_unsafe_protocols: bool = False,
10101011
allow_unsafe_options: bool = False,
1012+
url: Optional[str] = None,
1013+
token: Optional[str] = None,
1014+
dest_ref: Union[bool, str, None] = None,
10111015
**kwargs: Any,
10121016
) -> IterableList[FetchInfo]:
10131017
"""Fetch the latest changes for this remote.
@@ -1028,6 +1032,16 @@ def fetch(
10281032
does) - supplying a list rather than a string for 'refspec' will make use of
10291033
this facility.
10301034
1035+
:note:
1036+
A *bare* source name with no ``:<dst>`` (e.g. ``"main"``) only updates
1037+
``FETCH_HEAD`` when fetching from a remote by name, because git can
1038+
complete the destination from that remote's configured fetch refspec.
1039+
When fetching from an explicit ``url`` instead (no such config exists
1040+
for an anonymous URL), a bare name updates ``FETCH_HEAD`` only and
1041+
creates or updates no local ref at all. Use ``dest_ref`` below, or
1042+
spell out ``"<branch>:refs/remotes/<name>/<branch>"`` yourself, to get
1043+
a normal tracking ref out of a ``url=`` fetch.
1044+
10311045
:param progress:
10321046
See the :meth:`push` method.
10331047
@@ -1044,6 +1058,33 @@ def fetch(
10441058
:param allow_unsafe_options:
10451059
Allow unsafe options to be used, like ``--upload-pack``.
10461060
1061+
:param url:
1062+
Fetch from this URL instead of the remote's configured URL, for this
1063+
call only. The remote's stored config (``.git/config``) is never
1064+
written to or read for the transport target when this is given.
1065+
Takes precedence over the remote's name/configured url.
1066+
1067+
:param token:
1068+
Optional credential to embed in ``url`` for this call only (e.g. a
1069+
personal access token). Only used together with ``url``, and only
1070+
for ``http://``/``https://`` URLs. Never written to config, never
1071+
logged; kept out of :class:`FetchInfo`/exception text on failure.
1072+
1073+
:param dest_ref:
1074+
Only meaningful together with ``url``. Expands any *bare* (no ``:dst``)
1075+
entry in ``refspec`` into a full ``<branch>:<dst>`` mapping before
1076+
fetching, so the fetch updates a real local ref instead of only
1077+
``FETCH_HEAD``. Entries that already contain a ``:`` are left untouched.
1078+
1079+
- ``True``: derive ``dst`` as ``refs/remotes/<this-remote's-name>/<branch>``
1080+
for each bare entry -- i.e. behave like a normal named-remote fetch would.
1081+
- A string containing ``{branch}``: used as a template for ``dst``, e.g.
1082+
``"refs/remotes/upstream/{branch}"``.
1083+
- A plain string with no ``{branch}``: used verbatim as ``dst``. Only valid
1084+
when ``refspec`` is a single bare name, not a list.
1085+
- ``None`` (default): no expansion; bare names behave as plain git would
1086+
(``FETCH_HEAD`` only).
1087+
10471088
:param kwargs:
10481089
Additional arguments to be passed to :manpage:`git-fetch(1)`.
10491090
@@ -1055,16 +1096,41 @@ def fetch(
10551096
As fetch does not provide progress information to non-ttys, we cannot make
10561097
it available here unfortunately as in the :meth:`push` method.
10571098
"""
1058-
if refspec is None:
1099+
if url is None and refspec is None:
10591100
# No argument refspec, then ensure the repo's config has a fetch refspec.
10601101
self._assert_refspec()
10611102

1103+
if dest_ref is not None and url is None:
1104+
raise ValueError("dest_ref is only meaningful together with url=")
1105+
10621106
kwargs = add_progress(kwargs, self.repo.git, progress)
10631107
if isinstance(refspec, list):
10641108
args: Sequence[Optional[str]] = refspec
10651109
else:
10661110
args = [refspec]
10671111

1112+
if dest_ref is not None:
1113+
1114+
def _expand(ref: Optional[str]) -> Optional[str]:
1115+
if not ref or ":" in ref:
1116+
return ref
1117+
if dest_ref is True:
1118+
dst = f"refs/remotes/{self.name}/{ref}"
1119+
elif isinstance(dest_ref, str) and "{branch}" in dest_ref:
1120+
dst = dest_ref.format(branch=ref)
1121+
elif isinstance(dest_ref, str):
1122+
if isinstance(refspec, list) and len(refspec) > 1:
1123+
raise ValueError(
1124+
"dest_ref as a plain string only supports a single bare refspec entry; "
1125+
"use '{branch}' as a template, or pass True, for multiple entries"
1126+
)
1127+
dst = dest_ref
1128+
else:
1129+
raise TypeError("dest_ref must be True, a string, or None")
1130+
return f"{ref}:{dst}"
1131+
1132+
args = [_expand(ref) for ref in args]
1133+
10681134
if not allow_unsafe_protocols:
10691135
for ref in args:
10701136
if ref:
@@ -1076,10 +1142,41 @@ def fetch(
10761142
unsafe_options=self.unsafe_git_fetch_options,
10771143
)
10781144

1079-
proc = self.repo.git.fetch(
1080-
"--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs
1081-
)
1082-
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1145+
# Determine the fetch target: an explicit one-off `url` (optionally with an
1146+
# embedded `token`) takes precedence over the remote's configured name/url.
1147+
# Neither is ever written to .git/config -- it only affects this invocation.
1148+
fetch_target: Union[str, "Remote"] = self
1149+
if url is not None:
1150+
if not allow_unsafe_protocols:
1151+
Git.check_unsafe_protocols(url)
1152+
if token is not None:
1153+
parsed = urlsplit(url)
1154+
if parsed.scheme not in ("http", "https"):
1155+
raise ValueError("token= is only supported with http:// or https:// urls")
1156+
netloc = f"{token}@{parsed.hostname}"
1157+
if parsed.port:
1158+
netloc += f":{parsed.port}"
1159+
fetch_target = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
1160+
else:
1161+
fetch_target = url
1162+
1163+
try:
1164+
proc = self.repo.git.fetch(
1165+
"--",
1166+
fetch_target,
1167+
*args,
1168+
as_process=True,
1169+
with_stdout=False,
1170+
universal_newlines=True,
1171+
v=verbose,
1172+
**kwargs,
1173+
)
1174+
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1175+
except GitCommandError as err:
1176+
# Never let a token embedded in the URL leak into an exception message.
1177+
if token is not None and token in str(err):
1178+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1179+
raise
10831180
if hasattr(self.repo.odb, "update_cache"):
10841181
self.repo.odb.update_cache()
10851182
return res
@@ -1091,6 +1188,8 @@ def pull(
10911188
kill_after_timeout: Union[None, float] = None,
10921189
allow_unsafe_protocols: bool = False,
10931190
allow_unsafe_options: bool = False,
1191+
url: Optional[str] = None,
1192+
token: Optional[str] = None,
10941193
**kwargs: Any,
10951194
) -> IterableList[FetchInfo]:
10961195
"""Pull changes from the given branch, being the same as a fetch followed by a
@@ -1111,13 +1210,28 @@ def pull(
11111210
:param allow_unsafe_options:
11121211
Allow unsafe options to be used, like ``--upload-pack``.
11131212
1213+
:param url:
1214+
Pull from this URL instead of the remote's configured URL, for this
1215+
call only. The remote's stored config (``.git/config``) is never
1216+
written to or read for the transport target when this is given.
1217+
Takes precedence over the remote's name/configured url. As with plain
1218+
``git pull``, the fetched commit is merged into your currently checked
1219+
out branch regardless of the source, so use with the same care you
1220+
would use interactively.
1221+
1222+
:param token:
1223+
Optional credential to embed in ``url`` for this call only (e.g. a
1224+
personal access token). Only used together with ``url``, and only
1225+
for ``http://``/``https://`` URLs. Never written to config, never
1226+
logged; kept out of exception text on failure.
1227+
11141228
:param kwargs:
11151229
Additional arguments to be passed to :manpage:`git-pull(1)`.
11161230
11171231
:return:
11181232
Please see :meth:`fetch` method.
11191233
"""
1120-
if refspec is None:
1234+
if url is None and refspec is None:
11211235
# No argument refspec, then ensure the repo's config has a fetch refspec.
11221236
self._assert_refspec()
11231237
kwargs = add_progress(kwargs, self.repo.git, progress)
@@ -1133,10 +1247,40 @@ def pull(
11331247
unsafe_options=self.unsafe_git_pull_options,
11341248
)
11351249

1136-
proc = self.repo.git.pull(
1137-
"--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs
1138-
)
1139-
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1250+
# Same one-off-target mechanism as fetch(): an explicit `url` (optionally
1251+
# with an embedded `token`) takes precedence over the remote's configured
1252+
# name/url, and neither is ever written to .git/config.
1253+
pull_target: Union[str, "Remote"] = self
1254+
if url is not None:
1255+
if not allow_unsafe_protocols:
1256+
Git.check_unsafe_protocols(url)
1257+
if token is not None:
1258+
parsed = urlsplit(url)
1259+
if parsed.scheme not in ("http", "https"):
1260+
raise ValueError("token= is only supported with http:// or https:// urls")
1261+
netloc = f"{token}@{parsed.hostname}"
1262+
if parsed.port:
1263+
netloc += f":{parsed.port}"
1264+
pull_target = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
1265+
else:
1266+
pull_target = url
1267+
1268+
try:
1269+
proc = self.repo.git.pull(
1270+
"--",
1271+
pull_target,
1272+
refspec,
1273+
with_stdout=False,
1274+
as_process=True,
1275+
universal_newlines=True,
1276+
v=True,
1277+
**kwargs,
1278+
)
1279+
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1280+
except GitCommandError as err:
1281+
if token is not None and token in str(err):
1282+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1283+
raise
11401284
if hasattr(self.repo.odb, "update_cache"):
11411285
self.repo.odb.update_cache()
11421286
return res
@@ -1148,6 +1292,8 @@ def push(
11481292
kill_after_timeout: Union[None, float] = None,
11491293
allow_unsafe_protocols: bool = False,
11501294
allow_unsafe_options: bool = False,
1295+
url: Optional[str] = None,
1296+
token: Optional[str] = None,
11511297
**kwargs: Any,
11521298
) -> PushInfoList:
11531299
"""Push changes from source branch in refspec to target branch in refspec.
@@ -1180,6 +1326,18 @@ def push(
11801326
:param allow_unsafe_options:
11811327
Allow unsafe options to be used, like ``--receive-pack``.
11821328
1329+
:param url:
1330+
Push to this URL instead of the remote's configured URL, for this call
1331+
only. The remote's stored config (``.git/config``) is never written to
1332+
or read for the transport target when this is given. Takes precedence
1333+
over the remote's name/configured url.
1334+
1335+
:param token:
1336+
Optional credential to embed in ``url`` for this call only (e.g. a
1337+
personal access token). Only used together with ``url``, and only for
1338+
``http://``/``https://`` URLs. Never written to config, never logged;
1339+
kept out of exception text on failure.
1340+
11831341
:param kwargs:
11841342
Additional arguments to be passed to :manpage:`git-push(1)`.
11851343
@@ -1209,16 +1367,39 @@ def push(
12091367
unsafe_options=self.unsafe_git_push_options,
12101368
)
12111369

1212-
proc = self.repo.git.push(
1213-
"--",
1214-
self,
1215-
refspec,
1216-
porcelain=True,
1217-
as_process=True,
1218-
universal_newlines=True,
1219-
kill_after_timeout=kill_after_timeout,
1220-
**kwargs,
1221-
)
1370+
# Same one-off-target mechanism as fetch(): an explicit `url` (optionally
1371+
# with an embedded `token`) takes precedence over the remote's configured
1372+
# name/url, and neither is ever written to .git/config.
1373+
push_target: Union[str, "Remote"] = self
1374+
if url is not None:
1375+
if not allow_unsafe_protocols:
1376+
Git.check_unsafe_protocols(url)
1377+
if token is not None:
1378+
parsed = urlsplit(url)
1379+
if parsed.scheme not in ("http", "https"):
1380+
raise ValueError("token= is only supported with http:// or https:// urls")
1381+
netloc = f"{token}@{parsed.hostname}"
1382+
if parsed.port:
1383+
netloc += f":{parsed.port}"
1384+
push_target = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
1385+
else:
1386+
push_target = url
1387+
1388+
try:
1389+
proc = self.repo.git.push(
1390+
"--",
1391+
push_target,
1392+
refspec,
1393+
porcelain=True,
1394+
as_process=True,
1395+
universal_newlines=True,
1396+
kill_after_timeout=kill_after_timeout,
1397+
**kwargs,
1398+
)
1399+
except GitCommandError as err:
1400+
if token is not None and token in str(err):
1401+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1402+
raise
12221403
return self._get_push_info(proc, progress, kill_after_timeout=kill_after_timeout)
12231404

12241405
@property

0 commit comments

Comments
 (0)