Fix port preservation and HTTP fallback for local Gitea

- url.py: preserve non-standard ports in host field; ifc://host:3000/...
  now correctly generates https://host:3000/... rather than dropping :3000
- git.py: fall back from https to http on clone/fetch failure, enabling
  local Gitea instances running on plain HTTP; inject tokens into http://
  URLs as well as https://; extract _clone_bare() helper
- tests/test_url.py: three new tests for port preservation in host,
  git_remote_url with non-standard HTTPS port, and SSH with non-standard port
This commit is contained in:
Bruno Postle 2026-04-15 23:50:47 +01:00
parent 173cf653a8
commit 778f0004e7
3 changed files with 72 additions and 12 deletions

View file

@ -128,12 +128,35 @@ def _cache_dir_for(remote_url: str) -> Path:
def _auth_url(remote_url: str, token: str | None) -> str: def _auth_url(remote_url: str, token: str | None) -> str:
"""Return *remote_url* with the token injected, or the original URL.""" """Return *remote_url* with the token injected, or the original URL."""
if token and remote_url.startswith("https://"): if token and (remote_url.startswith("https://") or remote_url.startswith("http://")):
from ifcurl.auth import inject_token from ifcurl.auth import inject_token
return inject_token(remote_url, token) return inject_token(remote_url, token)
return remote_url return remote_url
def _http_fallback(url: str) -> str | None:
"""Return the http:// equivalent of an https:// URL, or None."""
if url.startswith("https://"):
return "http" + url[5:]
return None
def _clone_bare(auth_url: str, git_dir: Path, remote_url: str) -> git.Repo:
"""Clone *auth_url* as a bare repo to *git_dir*, falling back to http
when the server does not speak HTTPS (common for local dev instances).
"""
try:
return git.Repo.clone_from(auth_url, str(git_dir), bare=True)
except git.exc.GitCommandError as exc:
fallback = _http_fallback(auth_url)
if fallback:
try:
return git.Repo.clone_from(fallback, str(git_dir), bare=True)
except git.exc.GitCommandError:
pass # report the original error
raise ValueError(f"Failed to clone {remote_url!r}: {exc.stderr.strip()}") from exc
def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) -> git.Repo: def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) -> git.Repo:
"""Return a GitPython Repo for *remote_url*, cloning it if necessary. """Return a GitPython Repo for *remote_url*, cloning it if necessary.
@ -141,26 +164,35 @@ def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) ->
For mutable refs (branches, HEAD) the remote is fetched on every call. For mutable refs (branches, HEAD) the remote is fetched on every call.
Immutable refs (commit hashes, tags) skip the fetch. Immutable refs (commit hashes, tags) skip the fetch.
If *token* is provided it is injected into the HTTPS URL for clone and If *token* is provided it is injected into the URL for clone and fetch
fetch operations but is never written to the on-disk git config. operations but is never written to the on-disk git config.
HTTPS URLs automatically fall back to HTTP when the server does not
speak TLS this handles local Gitea instances running on HTTP.
""" """
cache_dir = _cache_dir_for(remote_url) cache_dir = _cache_dir_for(remote_url)
git_dir = cache_dir / "repo.git" git_dir = cache_dir / "repo.git"
auth = _auth_url(remote_url, token) auth = _auth_url(remote_url, token)
if not git_dir.exists(): if not git_dir.exists():
try: repo = _clone_bare(auth, git_dir, remote_url)
repo = git.Repo.clone_from(auth, str(git_dir), bare=True)
except git.exc.GitCommandError as exc:
raise ValueError(f"Failed to clone {remote_url!r}: {exc.stderr.strip()}") from exc
else: else:
repo = git.Repo(str(git_dir)) repo = git.Repo(str(git_dir))
if is_mutable: if is_mutable:
try: try:
if auth != remote_url: if auth != remote_url:
# Fetch from the authenticated URL directly so the token # Fetch directly from the authenticated URL so the token
# is never stored in the repo config. # is never stored in the repo config.
repo.git.fetch(auth, "+refs/*:refs/*") fetch_url = auth
fallback = _http_fallback(auth)
try:
repo.git.fetch(fetch_url, "+refs/*:refs/*")
except git.exc.GitCommandError:
if fallback:
try:
repo.git.fetch(fallback, "+refs/*:refs/*")
except git.exc.GitCommandError:
pass
else: else:
repo.git.fetch("origin") repo.git.fetch("origin")
except git.exc.GitCommandError: except git.exc.GitCommandError:

View file

@ -58,14 +58,20 @@ class IfcUrl:
raise ValueError(f"Expected ifc:// scheme, got {parsed.scheme!r}") raise ValueError(f"Expected ifc:// scheme, got {parsed.scheme!r}")
# --- Transport and authority --- # --- Transport and authority ---
# parsed.hostname strips the port; we rebuild host as hostname:port
# so that git_remote_url() generates correct clone URLs for
# non-standard ports (e.g. a local Gitea on :3000 or SSH on :2222).
def _host(p) -> str:
return f"{p.hostname}:{p.port}" if p.port else (p.hostname or "")
if parsed.username: if parsed.username:
transport = "ssh" transport = "ssh"
user: str | None = parsed.username user: str | None = parsed.username
host = parsed.hostname or "" host = _host(parsed)
elif parsed.netloc: elif parsed.netloc:
transport = "https" transport = "https"
user = None user = None
host = parsed.hostname or "" host = _host(parsed)
else: else:
transport = "local" transport = "local"
user = None user = None
@ -147,7 +153,14 @@ class IfcUrl:
) )
def git_remote_url(self) -> str: def git_remote_url(self) -> str:
"""Return the git remote URL for this IFC URL (SSH or HTTPS form).""" """Return the git remote URL for this IFC URL.
The port is preserved when non-standard. SSH uses ssh://, HTTPS
uses https://. Whether the server actually speaks HTTPS or plain
HTTP is resolved at clone time see ifcurl.git._open_remote which
falls back from https to http when the server refuses the TLS
handshake.
"""
if self.transport == "local": if self.transport == "local":
return self.repo_path return self.repo_path
if self.transport == "ssh": if self.transport == "ssh":

View file

@ -176,6 +176,21 @@ def test_git_remote_url_local():
assert url.git_remote_url() == "/home/alice/project" assert url.git_remote_url() == "/home/alice/project"
def test_git_remote_url_https_nonstandard_port():
url = IfcUrl.parse("ifc://localhost:3000/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "https://localhost:3000/org/repo"
def test_git_remote_url_ssh_nonstandard_port():
url = IfcUrl.parse("ifc://git@localhost:2222/org/repo@heads/main?path=m.ifc")
assert url.git_remote_url() == "ssh://git@localhost:2222/org/repo"
def test_host_preserves_port():
url = IfcUrl.parse("ifc://example.com:8080/org/repo@HEAD?path=m.ifc")
assert url.host == "example.com:8080"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# is_mutable_ref() # is_mutable_ref()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------