diff --git a/ifcurl/git.py b/ifcurl/git.py index ddad229..cee4b74 100644 --- a/ifcurl/git.py +++ b/ifcurl/git.py @@ -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: diff --git a/ifcurl/url.py b/ifcurl/url.py index fb5c677..250c0bf 100644 --- a/ifcurl/url.py +++ b/ifcurl/url.py @@ -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": diff --git a/tests/test_url.py b/tests/test_url.py index 9f6ded2..93bf52d 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -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() # ---------------------------------------------------------------------------