Skip to content

remote: add url=/token= to fetch/push/pull, plus dest_ref for fetch#2182

Closed
cumulus13 wants to merge 1 commit into
gitpython-developers:mainfrom
cumulus13:feature/remote-fetch-url-token
Closed

remote: add url=/token= to fetch/push/pull, plus dest_ref for fetch#2182
cumulus13 wants to merge 1 commit into
gitpython-developers:mainfrom
cumulus13:feature/remote-fetch-url-token

Conversation

@cumulus13

Copy link
Copy Markdown

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..fetch pattern to complete a destination from. dest_ref lets a caller ask for a normal tracking ref instead:

  • True -> refs/remotes//
  • '...{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.

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.
@cumulus13
cumulus13 force-pushed the feature/remote-fetch-url-token branch from 908a6c9 to 0ab00e7 Compare July 23, 2026 20:54
@Byron
Byron requested a review from Copilot July 24, 2026 03:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends git.remote.Remote transport methods to support one-off targets without mutating a remote’s persistent .git/config, enabling temporary url= and optional token= (credential-in-URL) for fetch(), push(), and pull(), plus a dest_ref helper for fetch() to avoid FETCH_HEAD-only updates when fetching from an anonymous URL.

Changes:

  • Add _resolve_transport_target() and thread url=/token= through Remote.fetch(), Remote.pull(), and Remote.push().
  • Add dest_ref expansion logic in Remote.fetch() to map bare refspecs to tracking refs when using one-off targets.
  • Add comprehensive tests covering explicit URL usage, token validation/redaction behavior, and dest_ref behaviors and validation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
git/remote.py Adds one-off transport target resolution (url/token) for fetch/pull/push and adds dest_ref expansion for fetch.
test/test_remote.py Adds test coverage for one-off URL operations, token validation/redaction, and dest_ref behaviors.
Comments suppressed due to low confidence (4)

git/remote.py:1255

  • The token parameter docs say it is “Only used together with url”, but pull() supports token= without url= via _resolve_transport_target. Please align the docstring with the implemented behavior.
        :param token:
            Optional credential to embed in ``url`` for this call only (e.g. a
            personal access token). Only used together with ``url``, and only
            for ``http://``/``https://`` URLs. Never written to config, never
            logged; kept out of exception text on failure.

git/remote.py:1358

  • The token parameter docs say it is “Only used together with url”, but push() supports token= without url= via _resolve_transport_target. Please align the docstring with the implemented behavior.
        :param token:
            Optional credential to embed in ``url`` for this call only (e.g. a
            personal access token). Only used together with ``url``, and only for
            ``http://``/``https://`` URLs. Never written to config, never logged;
            kept out of exception text on failure.

git/remote.py:1302

  • The token redaction here only rewrites err.args, but GitCommandError.__str__ uses err.command/err._cmdline and includes captured stderr/stdout. If git prints the credentialed URL in stderr, the token can leak. Scrub the rendered fields directly (as in fetch()).
        except GitCommandError as err:
            if token is not None and token in str(err):
                err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
            raise

git/remote.py:1411

  • The token redaction here only rewrites err.args, but GitCommandError.__str__ uses err.command/err._cmdline and includes captured stderr/stdout. If git prints the credentialed URL in stderr, the token can leak. Scrub the rendered fields directly (as in fetch()).
        except GitCommandError as err:
            if token is not None and token in str(err):
                err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
            raise

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread git/remote.py
Comment on lines +1039 to +1044
if parsed.scheme not in ("http", "https"):
raise ValueError("token= is only supported with http:// or https:// urls")
netloc = f"{token}@{parsed.hostname}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
Comment thread git/remote.py
Comment on lines +1164 to +1168
if isinstance(refspec, list) and len(refspec) > 1:
raise ValueError(
"dest_ref as a plain string only supports a single bare refspec entry; "
"use '{branch}' as a template, or pass True, for multiple entries"
)
Comment thread git/remote.py
Comment on lines +1109 to +1113
:param token:
Optional credential to embed in ``url`` for this call only (e.g. a
personal access token). Only used together with ``url``, and only
for ``http://``/``https://`` URLs. Never written to config, never
logged; kept out of :class:`FetchInfo`/exception text on failure.
Comment thread git/remote.py
Comment on lines +1204 to +1208
except GitCommandError as err:
# Never let a token embedded in the URL leak into an exception message.
if token is not None and token in str(err):
err.args = tuple(a.replace(token, "***") if isinstance(a, str) else a for a in err.args)
raise
@Byron

Byron commented Jul 24, 2026

Copy link
Copy Markdown
Member

Thanks for contributing!

However, given the size of the PR and what seems to me like a mix of unrelated features (like dst paired with url/token), along with security-sensitive flags, I don't feel comfortable reviewing it. My recommendation is to use these features from your fork, I'd expect them to keep applying cleanly given the stability of GitPython.

Thanks for your understanding.

@Byron Byron closed this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants