Skip to content

Commit 8987d1a

Browse files
authored
support scp-style shorthand urls with users other than git@ (#346)
* support scp-style shorthand urls with users other than git@ * lfs: support scp-style urls with username other than git * do not add .git to the path * add .git/info/lfs suffix to the lfs api url * fix regex
1 parent 1096899 commit 8987d1a

File tree

5 files changed

+91
-20
lines changed

5 files changed

+91
-20
lines changed

src/scmrepo/git/backend/pygit2/__init__.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import stat
5-
from collections.abc import Generator, Iterable, Iterator, Mapping
5+
from collections.abc import Iterable, Iterator, Mapping
66
from contextlib import contextmanager
77
from io import BytesIO, StringIO, TextIOWrapper
88
from typing import (
@@ -25,6 +25,7 @@
2525
from scmrepo.git.backend.base import BaseGitBackend, SyncStatus
2626
from scmrepo.git.config import Config
2727
from scmrepo.git.objects import GitCommit, GitObject, GitTag
28+
from scmrepo.urls import is_scp_style_url
2829
from scmrepo.utils import relpath
2930

3031
logger = logging.getLogger(__name__)
@@ -636,7 +637,7 @@ def _merge_remote_branch(
636637
raise SCMError("Unknown merge analysis result")
637638

638639
@contextmanager
639-
def _get_remote(self, url: str) -> Generator["Remote", None, None]:
640+
def _get_remote(self, url: str) -> Iterator["Remote"]:
640641
"""Return a pygit2.Remote suitable for the specified Git URL or remote name."""
641642
try:
642643
remote = self.repo.remotes[url]
@@ -646,11 +647,11 @@ def _get_remote(self, url: str) -> Generator["Remote", None, None]:
646647
except KeyError as exc:
647648
raise SCMError(f"'{url}' is not a valid Git remote or URL") from exc
648649

649-
if os.name == "nt" and url.startswith("file://"):
650-
url = url[len("file://") :]
650+
if os.name == "nt":
651+
url = url.removeprefix("file://")
651652
remote = self.repo.remotes.create_anonymous(url)
652653
parsed = urlparse(remote.url)
653-
if parsed.scheme in ("git", "git+ssh", "ssh") or remote.url.startswith("git@"):
654+
if parsed.scheme in ("git", "git+ssh", "ssh") or is_scp_style_url(remote.url):
654655
raise NotImplementedError
655656
yield remote
656657

src/scmrepo/git/lfs/client.py

+31-15
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import logging
33
import os
4-
import re
54
import shutil
65
from abc import abstractmethod
76
from collections.abc import Iterable, Iterator
@@ -10,6 +9,7 @@
109
from tempfile import NamedTemporaryFile
1110
from time import time
1211
from typing import TYPE_CHECKING, Any, Optional
12+
from urllib.parse import urlparse
1313

1414
import aiohttp
1515
from aiohttp_retry import ExponentialRetry, RetryClient
@@ -20,6 +20,7 @@
2020

2121
from scmrepo.git.backend.dulwich import _get_ssh_vendor
2222
from scmrepo.git.credentials import Credential, CredentialNotFoundError
23+
from scmrepo.urls import SCP_REGEX, is_scp_style_url
2324

2425
from .exceptions import LFSError
2526
from .pointer import Pointer
@@ -84,7 +85,7 @@ def loop(self):
8485

8586
@classmethod
8687
def from_git_url(cls, git_url: str) -> "LFSClient":
87-
if git_url.startswith(("ssh://", "git@")):
88+
if git_url.startswith("ssh://") or is_scp_style_url(git_url):
8889
return _SSHLFSClient.from_git_url(git_url)
8990
if git_url.startswith(("http://", "https://")):
9091
return _HTTPLFSClient.from_git_url(git_url)
@@ -213,11 +214,9 @@ def _get_auth_header(self, *, upload: bool) -> dict:
213214

214215

215216
class _SSHLFSClient(LFSClient):
216-
_URL_PATTERN = re.compile(
217-
r"(?:ssh://)?git@(?P<host>\S+?)(?::(?P<port>\d+))?(?:[:/])(?P<path>\S+?)\.git"
218-
)
219-
220-
def __init__(self, url: str, host: str, port: int, path: str):
217+
def __init__(
218+
self, url: str, host: str, port: int, username: Optional[str], path: str
219+
):
221220
"""
222221
Args:
223222
url: LFS server URL.
@@ -228,33 +227,50 @@ def __init__(self, url: str, host: str, port: int, path: str):
228227
super().__init__(url)
229228
self.host = host
230229
self.port = port
230+
self.username = username
231231
self.path = path
232232
self._ssh = _get_ssh_vendor()
233233

234234
@classmethod
235235
def from_git_url(cls, git_url: str) -> "_SSHLFSClient":
236-
result = cls._URL_PATTERN.match(git_url)
237-
if not result:
236+
if scp_match := SCP_REGEX.match(git_url):
237+
# Add an ssh:// prefix and replace the ':' with a '/'.
238+
git_url = scp_match.expand(r"ssh://\1\2/\3")
239+
240+
parsed = urlparse(git_url)
241+
if parsed.scheme != "ssh" or not parsed.hostname:
238242
raise ValueError(f"Invalid Git SSH URL: {git_url}")
239-
host, port, path = result.group("host", "port", "path")
240-
url = f"https://{host}/{path}.git/info/lfs"
241-
return cls(url, host, int(port or 22), path)
243+
244+
host = parsed.hostname
245+
port = parsed.port or 22
246+
path = parsed.path.lstrip("/")
247+
username = parsed.username
248+
249+
url_path = path.removesuffix(".git") + ".git/info/lfs"
250+
url = f"https://{host}/{url_path}"
251+
return cls(url, host, port, username, path)
242252

243253
def _get_auth_header(self, *, upload: bool) -> dict:
244254
return self._git_lfs_authenticate(
245-
self.host, self.port, f"{self.path}.git", upload=upload
255+
self.host, self.port, self.username, self.path, upload=upload
246256
).get("header", {})
247257

248258
def _git_lfs_authenticate(
249-
self, host: str, port: int, path: str, *, upload: bool = False
259+
self,
260+
host: str,
261+
port: int,
262+
username: Optional[str],
263+
path: str,
264+
*,
265+
upload: bool = False,
250266
) -> dict:
251267
action = "upload" if upload else "download"
252268
return json.loads(
253269
self._ssh.run_command(
254270
command=f"git-lfs-authenticate {path} {action}",
255271
host=host,
256272
port=port,
257-
username="git",
273+
username=username,
258274
).read()
259275
)
260276

src/scmrepo/urls.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import re
2+
3+
# from https://github.com/pypa/pip/blob/303fed36c1771de4063063a866776a9103972317/src/pip/_internal/vcs/git.py#L40
4+
# SCP (Secure copy protocol) shorthand. e.g. '[email protected]:foo/bar.git'
5+
SCP_REGEX = re.compile(
6+
r"""^
7+
# Optional user, e.g. 'git@'
8+
(\w+@)?
9+
# Server, e.g. 'github.com'.
10+
([^/:]+):
11+
# The server-side path. e.g. 'user/project.git'. Must start with an
12+
# alphanumeric character so as not to be confusable with a Windows paths
13+
# like 'C:/foo/bar' or 'C:\foo\bar'.
14+
(\w[^:]*)
15+
$""",
16+
re.VERBOSE,
17+
)
18+
19+
20+
def is_scp_style_url(url: str) -> bool:
21+
return bool(SCP_REGEX.match(url))

tests/test_pygit2.py

+2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def test_pygit_stash_apply_conflicts(
6868
"url",
6969
[
7070
"[email protected]:iterative/scmrepo.git",
71+
"github.com:iterative/scmrepo.git",
72+
"[email protected]:iterative/scmrepo.git",
7173
"ssh://[email protected]:12345/repository.git",
7274
],
7375
)

tests/test_urls.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import pytest
2+
3+
from scmrepo.urls import is_scp_style_url
4+
5+
6+
@pytest.mark.parametrize(
7+
"url",
8+
[
9+
"[email protected]:iterative/scmrepo.git",
10+
"github.com:iterative/scmrepo.git",
11+
"[email protected]:iterative/scmrepo.git",
12+
],
13+
)
14+
def test_scp_url(url: str):
15+
assert is_scp_style_url(url)
16+
17+
18+
@pytest.mark.parametrize(
19+
"url",
20+
[
21+
r"C:\foo\bar",
22+
"C:/foo/bar",
23+
"/home/user/iterative/scmrepo/git",
24+
"~/iterative/scmrepo/git",
25+
"ssh://[email protected]:12345/repository.git",
26+
"https://user:[email protected]/iterative/scmrepo.git",
27+
"https://github.com/iterative/scmrepo.git",
28+
],
29+
)
30+
def test_scp_url_invalid(url: str):
31+
assert not is_scp_style_url(url)

0 commit comments

Comments
 (0)