changeset 0:c1dc9d21fa57

First cut at serving hg repos with git.
author Paul Fisher <paul@pfish.zone>
date Sat, 14 Feb 2026 05:48:46 -0500
parents
children a39dd69b8972
files .hgignore gitserve.py
diffstat 2 files changed, 112 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Sat Feb 14 05:48:46 2026 -0500
@@ -0,0 +1,4 @@
+syntax: rootglob
+.venv/
+# Default ignored files
+.idea/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gitserve.py	Sat Feb 14 05:48:46 2026 -0500
@@ -0,0 +1,108 @@
+import email.parser
+import email.policy
+import re
+import threading
+import shutil
+import subprocess
+import typing as t
+
+import mercurial.hgweb.hgweb_mod_inner as web_inner
+import mercurial.hgweb.request as hgreq
+import mercurial.ui as hgui
+from mercurial import extensions
+from mercurial import wireprotoserver
+
+type PermissionCheck = t.Callable[
+    [web_inner.requestcontext, hgreq.parsedrequest, bytes],
+    None,
+]
+
+
+CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
+
+
+def build_git_environ(
+    req_ctx: web_inner.requestcontext,
+    request: hgreq.parsedrequest,
+) -> dict[bytes, bytes]:
+    """Builds the environment to be sent to Git to serve HTTP."""
+    fixed = {
+        k: v for (k, v) in request.rawenv.items()
+        if isinstance(v, bytes) and CGI_VAR.match(k)
+    }
+    fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes'
+    fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path
+    fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath
+    return fixed
+
+
+def parse_cgi_response(output: t.IO[bytes]) -> t.Tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
+    parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
+    while line := output.readline():
+        if not line.rstrip(b'\r\n'):
+            # We've reached the end of the headers.
+            # Leave the rest in the output for later.
+            break
+        parser.feed(line)
+    msg = parser.close()
+    status = msg.get('Status', '200 OK I guess').encode('utf-8')
+    del msg['Status']  # this won't raise an exception
+    byte_headers = {k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()}
+    return status, byte_headers, output
+
+
+_feeds = 0
+_feeder_lock = threading.Lock()
+
+
+def feed_count() -> int:
+    global _feeds
+    with _feeder_lock:
+        _feeds += 1
+        return _feeds
+
+
+def handle_git_protocol(
+    original: t.Callable[..., bool],
+    req_ctx: web_inner.requestcontext,
+    request: hgreq.parsedrequest,
+    response: hgreq.wsgiresponse,
+    check_permission: PermissionCheck,
+) -> bool:
+    if request.headers.get(b'git-protocol'):
+        # If a request is git, we assume we should be the one handling it.
+        cgi_env = build_git_environ(req_ctx, request)
+        http_backend = req_ctx.repo.ui.configlist(
+            b'git-serve', b'http-backend', default=(b'git', b'http-backend'))
+        is_post = request.method == b'POST'
+        call = subprocess.Popen(
+            http_backend,
+            close_fds=True,
+            stdin=subprocess.PIPE if is_post else None,
+            stdout=subprocess.PIPE,
+            env=cgi_env,
+            text=False,
+        )
+        def feed():
+            try:
+                with call.stdin as stdin:
+                   shutil.copyfileobj(request.bodyfh, stdin)
+            except (OSError, BrokenPipeError):
+                pass  # Expected; this just means it's closed.
+        if is_post:
+            threading.Thread(target=feed, name=f'git-feeder-{feed_count()}').start()
+        status, headers, rest = parse_cgi_response(call.stdout)
+        response.status = status
+        for k, v in headers.items():
+            response.headers[k] = v
+        def write_the_rest():
+            with call, rest:
+                while more := rest.read(64 * 1024):
+                    yield more
+        response.setbodygen(write_the_rest())
+        response.sendresponse()
+        return True
+    return original(req_ctx, request, response, check_permission)
+
+def uisetup(_: hgui.ui) -> None:
+    extensions.wrapfunction(wireprotoserver, 'handlewsgirequest', handle_git_protocol)
\ No newline at end of file