Skip to content

Commit 0ab00e7

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 to embed into the transport target for this call only (http/https only). If url= is also given, the token is embedded into that url. If url= is omitted, the token is embedded into this remote's own *currently configured* url instead (read only, never written) -- so a caller can pass just token= on an ordinary named remote without repeating a url they already have configured, and without that token ever silently doing nothing. 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. All three methods resolve url=/token= identically via a shared Remote._resolve_transport_target() helper. 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 explicit url/token target, 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=/token=, 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=/token= is given, token= rejecting non-http(s) urls (both explicit and derived-from-remote) on all three methods, token redaction in the raised GitCommandError on failure for all three, token= alone deriving and embedding into the remote's own configured url on all three methods, 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 0ab00e7

2 files changed

Lines changed: 574 additions & 20 deletions

File tree

git/remote.py

Lines changed: 210 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
@@ -1000,6 +1001,48 @@ def _assert_refspec(self) -> None:
10001001
finally:
10011002
config.release()
10021003

1004+
def _resolve_transport_target(
1005+
self,
1006+
url: Optional[str],
1007+
token: Optional[str],
1008+
allow_unsafe_protocols: bool,
1009+
) -> Union[str, "Remote"]:
1010+
"""Determine the one-off target to fetch/push/pull against for this call.
1011+
1012+
Used by :meth:`fetch`, :meth:`push`, and :meth:`pull` so all three resolve
1013+
``url``/``token`` the same way:
1014+
1015+
- ``url`` given: use it verbatim (with ``token`` embedded into it, if given).
1016+
- ``url`` is ``None`` but ``token`` is given: read (never write) this
1017+
remote's own *currently configured* URL and embed the token into that,
1018+
so a caller can pass just ``token=`` on a normal named remote without
1019+
repeating a URL they already have configured -- still never touching
1020+
``.git/config``.
1021+
- Neither given: return ``self`` unchanged, i.e. today's existing
1022+
behaviour (fetch/push/pull by remote name, using its configured URL as
1023+
git itself resolves it).
1024+
1025+
:raises ValueError:
1026+
If ``token`` is given but the resolved URL is not ``http://``/``https://``.
1027+
"""
1028+
effective_url = url if url is not None else (self.url if token is not None else None)
1029+
if effective_url is None:
1030+
return self
1031+
1032+
if not allow_unsafe_protocols:
1033+
Git.check_unsafe_protocols(effective_url)
1034+
1035+
if token is None:
1036+
return effective_url
1037+
1038+
parsed = urlsplit(effective_url)
1039+
if parsed.scheme not in ("http", "https"):
1040+
raise ValueError("token= is only supported with http:// or https:// urls")
1041+
netloc = f"{token}@{parsed.hostname}"
1042+
if parsed.port:
1043+
netloc += f":{parsed.port}"
1044+
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
1045+
10031046
def fetch(
10041047
self,
10051048
refspec: Union[str, List[str], None] = None,
@@ -1008,6 +1051,9 @@ def fetch(
10081051
kill_after_timeout: Union[None, float] = None,
10091052
allow_unsafe_protocols: bool = False,
10101053
allow_unsafe_options: bool = False,
1054+
url: Optional[str] = None,
1055+
token: Optional[str] = None,
1056+
dest_ref: Union[bool, str, None] = None,
10111057
**kwargs: Any,
10121058
) -> IterableList[FetchInfo]:
10131059
"""Fetch the latest changes for this remote.
@@ -1028,6 +1074,16 @@ def fetch(
10281074
does) - supplying a list rather than a string for 'refspec' will make use of
10291075
this facility.
10301076
1077+
:note:
1078+
A *bare* source name with no ``:<dst>`` (e.g. ``"main"``) only updates
1079+
``FETCH_HEAD`` when fetching from a remote by name, because git can
1080+
complete the destination from that remote's configured fetch refspec.
1081+
When fetching from an explicit ``url`` instead (no such config exists
1082+
for an anonymous URL), a bare name updates ``FETCH_HEAD`` only and
1083+
creates or updates no local ref at all. Use ``dest_ref`` below, or
1084+
spell out ``"<branch>:refs/remotes/<name>/<branch>"`` yourself, to get
1085+
a normal tracking ref out of a ``url=`` fetch.
1086+
10311087
:param progress:
10321088
See the :meth:`push` method.
10331089
@@ -1044,6 +1100,33 @@ def fetch(
10441100
:param allow_unsafe_options:
10451101
Allow unsafe options to be used, like ``--upload-pack``.
10461102
1103+
:param url:
1104+
Fetch from this URL instead of the remote's configured URL, for this
1105+
call only. The remote's stored config (``.git/config``) is never
1106+
written to or read for the transport target when this is given.
1107+
Takes precedence over the remote's name/configured url.
1108+
1109+
:param token:
1110+
Optional credential to embed in ``url`` for this call only (e.g. a
1111+
personal access token). Only used together with ``url``, and only
1112+
for ``http://``/``https://`` URLs. Never written to config, never
1113+
logged; kept out of :class:`FetchInfo`/exception text on failure.
1114+
1115+
:param dest_ref:
1116+
Only meaningful together with ``url``. Expands any *bare* (no ``:dst``)
1117+
entry in ``refspec`` into a full ``<branch>:<dst>`` mapping before
1118+
fetching, so the fetch updates a real local ref instead of only
1119+
``FETCH_HEAD``. Entries that already contain a ``:`` are left untouched.
1120+
1121+
- ``True``: derive ``dst`` as ``refs/remotes/<this-remote's-name>/<branch>``
1122+
for each bare entry -- i.e. behave like a normal named-remote fetch would.
1123+
- A string containing ``{branch}``: used as a template for ``dst``, e.g.
1124+
``"refs/remotes/upstream/{branch}"``.
1125+
- A plain string with no ``{branch}``: used verbatim as ``dst``. Only valid
1126+
when ``refspec`` is a single bare name, not a list.
1127+
- ``None`` (default): no expansion; bare names behave as plain git would
1128+
(``FETCH_HEAD`` only).
1129+
10471130
:param kwargs:
10481131
Additional arguments to be passed to :manpage:`git-fetch(1)`.
10491132
@@ -1055,16 +1138,41 @@ def fetch(
10551138
As fetch does not provide progress information to non-ttys, we cannot make
10561139
it available here unfortunately as in the :meth:`push` method.
10571140
"""
1058-
if refspec is None:
1141+
if url is None and token is None and refspec is None:
10591142
# No argument refspec, then ensure the repo's config has a fetch refspec.
10601143
self._assert_refspec()
10611144

1145+
if dest_ref is not None and url is None and token is None:
1146+
raise ValueError("dest_ref is only meaningful together with url= or token=")
1147+
10621148
kwargs = add_progress(kwargs, self.repo.git, progress)
10631149
if isinstance(refspec, list):
10641150
args: Sequence[Optional[str]] = refspec
10651151
else:
10661152
args = [refspec]
10671153

1154+
if dest_ref is not None:
1155+
1156+
def _expand(ref: Optional[str]) -> Optional[str]:
1157+
if not ref or ":" in ref:
1158+
return ref
1159+
if dest_ref is True:
1160+
dst = f"refs/remotes/{self.name}/{ref}"
1161+
elif isinstance(dest_ref, str) and "{branch}" in dest_ref:
1162+
dst = dest_ref.format(branch=ref)
1163+
elif isinstance(dest_ref, str):
1164+
if isinstance(refspec, list) and len(refspec) > 1:
1165+
raise ValueError(
1166+
"dest_ref as a plain string only supports a single bare refspec entry; "
1167+
"use '{branch}' as a template, or pass True, for multiple entries"
1168+
)
1169+
dst = dest_ref
1170+
else:
1171+
raise TypeError("dest_ref must be True, a string, or None")
1172+
return f"{ref}:{dst}"
1173+
1174+
args = [_expand(ref) for ref in args]
1175+
10681176
if not allow_unsafe_protocols:
10691177
for ref in args:
10701178
if ref:
@@ -1076,10 +1184,28 @@ def fetch(
10761184
unsafe_options=self.unsafe_git_fetch_options,
10771185
)
10781186

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)
1187+
# Determine the fetch target: an explicit `url=`, or a `token=` embedded into
1188+
# this remote's own configured url (read, never written), takes precedence
1189+
# over plain named-remote fetching. Nothing is ever written to .git/config.
1190+
fetch_target = self._resolve_transport_target(url, token, allow_unsafe_protocols)
1191+
1192+
try:
1193+
proc = self.repo.git.fetch(
1194+
"--",
1195+
fetch_target,
1196+
*args,
1197+
as_process=True,
1198+
with_stdout=False,
1199+
universal_newlines=True,
1200+
v=verbose,
1201+
**kwargs,
1202+
)
1203+
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1204+
except GitCommandError as err:
1205+
# Never let a token embedded in the URL leak into an exception message.
1206+
if token is not None and token in str(err):
1207+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1208+
raise
10831209
if hasattr(self.repo.odb, "update_cache"):
10841210
self.repo.odb.update_cache()
10851211
return res
@@ -1091,6 +1217,8 @@ def pull(
10911217
kill_after_timeout: Union[None, float] = None,
10921218
allow_unsafe_protocols: bool = False,
10931219
allow_unsafe_options: bool = False,
1220+
url: Optional[str] = None,
1221+
token: Optional[str] = None,
10941222
**kwargs: Any,
10951223
) -> IterableList[FetchInfo]:
10961224
"""Pull changes from the given branch, being the same as a fetch followed by a
@@ -1111,13 +1239,28 @@ def pull(
11111239
:param allow_unsafe_options:
11121240
Allow unsafe options to be used, like ``--upload-pack``.
11131241
1242+
:param url:
1243+
Pull from this URL instead of the remote's configured URL, for this
1244+
call only. The remote's stored config (``.git/config``) is never
1245+
written to or read for the transport target when this is given.
1246+
Takes precedence over the remote's name/configured url. As with plain
1247+
``git pull``, the fetched commit is merged into your currently checked
1248+
out branch regardless of the source, so use with the same care you
1249+
would use interactively.
1250+
1251+
:param token:
1252+
Optional credential to embed in ``url`` for this call only (e.g. a
1253+
personal access token). Only used together with ``url``, and only
1254+
for ``http://``/``https://`` URLs. Never written to config, never
1255+
logged; kept out of exception text on failure.
1256+
11141257
:param kwargs:
11151258
Additional arguments to be passed to :manpage:`git-pull(1)`.
11161259
11171260
:return:
11181261
Please see :meth:`fetch` method.
11191262
"""
1120-
if refspec is None:
1263+
if url is None and token is None and refspec is None:
11211264
# No argument refspec, then ensure the repo's config has a fetch refspec.
11221265
self._assert_refspec()
11231266
kwargs = add_progress(kwargs, self.repo.git, progress)
@@ -1133,10 +1276,30 @@ def pull(
11331276
unsafe_options=self.unsafe_git_pull_options,
11341277
)
11351278

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)
1279+
# Same one-off-target mechanism as fetch(): an explicit `url` (optionally
1280+
# with an embedded `token`) takes precedence over the remote's configured
1281+
# name/url, and neither is ever written to .git/config.
1282+
# Same one-off-target resolution as fetch(): an explicit `url=`, or a
1283+
# `token=` embedded into this remote's own configured url (read, never
1284+
# written), takes precedence over plain named-remote pulling.
1285+
pull_target = self._resolve_transport_target(url, token, allow_unsafe_protocols)
1286+
1287+
try:
1288+
proc = self.repo.git.pull(
1289+
"--",
1290+
pull_target,
1291+
refspec,
1292+
with_stdout=False,
1293+
as_process=True,
1294+
universal_newlines=True,
1295+
v=True,
1296+
**kwargs,
1297+
)
1298+
res = self._get_fetch_info_from_stderr(proc, progress, kill_after_timeout=kill_after_timeout)
1299+
except GitCommandError as err:
1300+
if token is not None and token in str(err):
1301+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1302+
raise
11401303
if hasattr(self.repo.odb, "update_cache"):
11411304
self.repo.odb.update_cache()
11421305
return res
@@ -1148,6 +1311,8 @@ def push(
11481311
kill_after_timeout: Union[None, float] = None,
11491312
allow_unsafe_protocols: bool = False,
11501313
allow_unsafe_options: bool = False,
1314+
url: Optional[str] = None,
1315+
token: Optional[str] = None,
11511316
**kwargs: Any,
11521317
) -> PushInfoList:
11531318
"""Push changes from source branch in refspec to target branch in refspec.
@@ -1180,6 +1345,18 @@ def push(
11801345
:param allow_unsafe_options:
11811346
Allow unsafe options to be used, like ``--receive-pack``.
11821347
1348+
:param url:
1349+
Push to this URL instead of the remote's configured URL, for this call
1350+
only. The remote's stored config (``.git/config``) is never written to
1351+
or read for the transport target when this is given. Takes precedence
1352+
over the remote's name/configured url.
1353+
1354+
:param token:
1355+
Optional credential to embed in ``url`` for this call only (e.g. a
1356+
personal access token). Only used together with ``url``, and only for
1357+
``http://``/``https://`` URLs. Never written to config, never logged;
1358+
kept out of exception text on failure.
1359+
11831360
:param kwargs:
11841361
Additional arguments to be passed to :manpage:`git-push(1)`.
11851362
@@ -1209,16 +1386,29 @@ def push(
12091386
unsafe_options=self.unsafe_git_push_options,
12101387
)
12111388

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-
)
1389+
# Same one-off-target mechanism as fetch(): an explicit `url` (optionally
1390+
# with an embedded `token`) takes precedence over the remote's configured
1391+
# name/url, and neither is ever written to .git/config.
1392+
# Same one-off-target resolution as fetch(): an explicit `url=`, or a
1393+
# `token=` embedded into this remote's own configured url (read, never
1394+
# written), takes precedence over plain named-remote pushing.
1395+
push_target = self._resolve_transport_target(url, token, allow_unsafe_protocols)
1396+
1397+
try:
1398+
proc = self.repo.git.push(
1399+
"--",
1400+
push_target,
1401+
refspec,
1402+
porcelain=True,
1403+
as_process=True,
1404+
universal_newlines=True,
1405+
kill_after_timeout=kill_after_timeout,
1406+
**kwargs,
1407+
)
1408+
except GitCommandError as err:
1409+
if token is not None and token in str(err):
1410+
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
1411+
raise
12221412
return self._get_push_info(proc, progress, kill_after_timeout=kill_after_timeout)
12231413

12241414
@property

0 commit comments

Comments
 (0)