Mercurial > hg-git-serve
diff src/hgext3rd/hggit_serve/_http.py @ 12:f630d9904ea7
Reorganize project into multiple files.
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Wed, 18 Feb 2026 16:17:05 -0500 |
| parents | src/hgext3rd/hggit_serve.py@ce204bcc4e04 |
| children | 00bdfac5416c |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/hgext3rd/hggit_serve/_http.py Wed Feb 18 16:17:05 2026 -0500 @@ -0,0 +1,180 @@ +from __future__ import annotations + +import email.parser +import email.policy +import re +import shutil +import subprocess +import typing as t + +from mercurial import extensions +from mercurial import wireprotoserver +from mercurial.thirdparty import attr + +from . import _export as xp + +if t.TYPE_CHECKING: + import mercurial.hgweb.hgweb_mod_inner as web_inner + import mercurial.hgweb.request as hgreq + import mercurial.interfaces.repository as hgrepo + import mercurial.ui as hgui + + PermissionCheck = t.Callable[ + [web_inner.requestcontext, hgreq.parsedrequest, bytes], + None, + ] + + +_CGI_VAR = re.compile(rb'[A-Z0-9_]+$') +"""Environment variables that we need to pass to git-as-cgi.""" + + +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.update( + { + b'GIT_HTTP_EXPORT_ALL': b'yes', + b'GIT_PROJECT_ROOT': req_ctx.repo.path, + b'PATH_INFO': b'/git/' + request.dispatchpath, + # Since Mercurial is taking care of authorization checking, + # we tell Git to always allow push. + b'GIT_CONFIG_COUNT': b'1', + b'GIT_CONFIG_KEY_0': b'http.receivepack', + b'GIT_CONFIG_VALUE_0': b'true', + } + ) + return fixed + + +def _parse_cgi_response( + output: t.IO[bytes], +) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: + """Parses a CGI response into a status, headers, and everyhting else.""" + 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 + + +def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None: + """Figures out what Mercurial permission corresponds to a request from Git. + + If the request is a supported Git action, returns the permission it needs. + If the request is not a Git action, returns None. + """ + if perm := xp.SERVICE_PERMISSIONS.get(request.dispatchpath): + return perm + if request.dispatchpath != b'info/refs': + return None + qs = request.querystring + service = qs.removeprefix(b'service=') + if qs == service: + # Nothing was stripped. + return None + return xp.SERVICE_PERMISSIONS.get(service) + + +def _handle_git_protocol( + original: t.Callable[..., bool], + req_ctx: web_inner.requestcontext, + request: hgreq.parsedrequest, + response: hgreq.wsgiresponse, + check_permission: PermissionCheck, +) -> bool: + """Intercepts requests from Git, if needed.""" + perm = _git_service_permission(request) + repo: hgrepo.IRepo = req_ctx.repo + if not perm or not xp.is_gitty(repo): + # We only handle Git requests to Gitty repos. + return original(req_ctx, request, response, check_permission) + + # Permission workaround: Mercurial requires POSTs for push, + # but the advertisement request from Git will be a GET. + # We just lie to Mercurial about what we're doing. + check_permission( + req_ctx, + ( + attr.evolve(req_ctx.req, method=b'POST') + if perm == xp.PUSH + else req_ctx.req + ), + perm, + ) + cgi_env = _build_git_environ(req_ctx, request) + http_backend = repo.ui.configlist( + b'hggit-serve', b'http-backend', default=(b'git', b'http-backend') + ) + call = subprocess.Popen( + http_backend, + close_fds=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=cgi_env, + text=False, + ) + assert call.stdout + assert call.stdin + # Git will not start writing output until stdin is fully closed. + with call.stdin: + # This is how we know if there's anything to read from bodyfh. + # If we try to read from bodyfh on a request with no content, + # it hangs forever. + if b'CONTENT_LENGTH' in request.rawenv: + shutil.copyfileobj(request.bodyfh, call.stdin) + + 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() -> t.Iterator[bytes]: + with call, rest: + # if it's good enough for shutil it's good enough for me + # technically not in the docs but everybody it + bs = shutil.COPY_BUFSIZE # type: ignore[attr-defined] + while more := rest.read(bs): + yield more + if perm == xp.PUSH: + # If we pushed, we need to import any new refs back into Mercurial. + xp.importing_enter(repo) + try: + gh = repo.githandler + gh.import_git_objects( + b'git-push', remote_names=(), refs=gh.git.refs.as_dict() + ) + finally: + xp.importing_exit(repo) + + response.setbodygen(write_the_rest()) + response.sendresponse() + return True + + +# +# Interfacing with Mercurial +# + + +def uisetup(_: hgui.ui) -> None: + extensions.wrapfunction( + wireprotoserver, 'handlewsgirequest', _handle_git_protocol + )
