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:
"""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
return inject_token(remote_url, token)
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:
"""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.
Immutable refs (commit hashes, tags) skip the fetch.
If *token* is provided it is injected into the HTTPS URL for clone and
fetch operations but is never written to the on-disk git config.
If *token* is provided it is injected into the URL for clone and fetch
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)
git_dir = cache_dir / "repo.git"
auth = _auth_url(remote_url, token)
if not git_dir.exists():
try:
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
repo = _clone_bare(auth, git_dir, remote_url)
else:
repo = git.Repo(str(git_dir))
if is_mutable:
try:
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.
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:
repo.git.fetch("origin")
except git.exc.GitCommandError:

View file

@ -58,14 +58,20 @@ class IfcUrl:
raise ValueError(f"Expected ifc:// scheme, got {parsed.scheme!r}")
# --- 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:
transport = "ssh"
user: str | None = parsed.username
host = parsed.hostname or ""
host = _host(parsed)
elif parsed.netloc:
transport = "https"
user = None
host = parsed.hostname or ""
host = _host(parsed)
else:
transport = "local"
user = None
@ -147,7 +153,14 @@ class IfcUrl:
)
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":
return self.repo_path
if self.transport == "ssh":

View file

@ -176,6 +176,21 @@ def test_git_remote_url_local():
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()
# ---------------------------------------------------------------------------