From c4e5d0cc43fe06fb2b8e9e6ddd08aed82d365a00 Mon Sep 17 00:00:00 2001 From: Eugene Cohen Date: Fri, 17 Jul 2026 15:21:41 -0700 Subject: [PATCH] fix relative URL resolution for SCP-style SSH remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit urljoin() only handled scheme:// URLs and truly-relative paths. SCP-style remotes (git@host:path), which urllib does not parse as URLs, fell through to plain string concatenation, so a dependency URL of ../dep.git against a base of git@host:group/main.git produced git@host:group/main.git/../dep.git instead of git@host:group/dep.git — leaving git to fail the clone. Detect SCP syntax by git's own rule (a colon before any slash, no scheme://), normalize the ".." within the path, and keep the "host:" prefix so the result stays valid SCP syntax. Also covers the user-less host:path form, which urllib otherwise mis-parses as having a scheme. --- src/gitws/_url.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/gitws/_url.py b/src/gitws/_url.py index e29db72..875acba 100644 --- a/src/gitws/_url.py +++ b/src/gitws/_url.py @@ -16,10 +16,20 @@ """Set of URL Helper Functions.""" +import re from os.path import relpath from typing import Optional from urllib import parse +# SCP-like SSH syntax ``[user@]host:path``: a colon before any slash and no +# ``scheme://``. urllib cannot parse these as URLs, so relative paths in them need +# explicit resolution. This mirrors git's own rule for recognizing them. +_RE_SCP = re.compile(r"^[^/]+:") + + +def _is_scp(url: str) -> bool: + return "://" not in url and bool(_RE_SCP.match(url)) + def urljoin(base: Optional[str], url: str) -> str: """ @@ -41,6 +51,15 @@ def urljoin(base: Optional[str], url: str) -> str: >>> urljoin('ssh://domain.com/base/repo1.git', '../repo2.git') 'ssh://domain.com/base/repo2.git' + >>> urljoin('git@domain.com:base/repo1.git', '../repo2.git') + 'git@domain.com:base/repo2.git' + >>> urljoin('git@domain.com:base/repo1.git/', 'repo2.git') + 'git@domain.com:base/repo1.git/repo2.git' + >>> urljoin('git@domain.com:base/repo1.git', 'git@domain.com:base/repo2.git') + 'git@domain.com:base/repo2.git' + >>> urljoin('domain.com:base/repo1.git', '../repo2.git') + 'domain.com:base/repo2.git' + >>> urljoin('..', 'repo1.git') '../repo1.git' >>> urljoin('../../', 'repo1.git') @@ -56,13 +75,23 @@ def urljoin(base: Optional[str], url: str) -> str: if not base: return url - urlparsed = parse.urlparse(url) - if urlparsed.scheme: - # absolute url + if is_urlabs(url) or _is_scp(url): + # absolute url (``scheme://…`` or SCP-like ``host:path``) return url if not base.endswith("/"): base = f"{base}/" + + if _is_scp(base): + # SCP-like SSH: normalize the path, keep the ``location:`` prefix so the + # result stays SCP syntax (git accepts it, but urllib cannot parse it). + location, _, path = base.partition(":") + joined = parse.urljoin(f"http://scp/{path.lstrip('/')}", url) + newpath = parse.urlparse(joined).path + if not path.startswith("/"): + newpath = newpath.lstrip("/") + return f"{location}:{newpath}" + baseparsed = parse.urlparse(base) if not baseparsed.scheme: # relative base