Mercurial > hg-git-serve
comparison gitserve.py @ 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 |
comparison
equal
deleted
inserted
replaced
| -1:000000000000 | 0:c1dc9d21fa57 |
|---|---|
| 1 import email.parser | |
| 2 import email.policy | |
| 3 import re | |
| 4 import threading | |
| 5 import shutil | |
| 6 import subprocess | |
| 7 import typing as t | |
| 8 | |
| 9 import mercurial.hgweb.hgweb_mod_inner as web_inner | |
| 10 import mercurial.hgweb.request as hgreq | |
| 11 import mercurial.ui as hgui | |
| 12 from mercurial import extensions | |
| 13 from mercurial import wireprotoserver | |
| 14 | |
| 15 type PermissionCheck = t.Callable[ | |
| 16 [web_inner.requestcontext, hgreq.parsedrequest, bytes], | |
| 17 None, | |
| 18 ] | |
| 19 | |
| 20 | |
| 21 CGI_VAR = re.compile(rb'[A-Z0-9_]+$') | |
| 22 | |
| 23 | |
| 24 def build_git_environ( | |
| 25 req_ctx: web_inner.requestcontext, | |
| 26 request: hgreq.parsedrequest, | |
| 27 ) -> dict[bytes, bytes]: | |
| 28 """Builds the environment to be sent to Git to serve HTTP.""" | |
| 29 fixed = { | |
| 30 k: v for (k, v) in request.rawenv.items() | |
| 31 if isinstance(v, bytes) and CGI_VAR.match(k) | |
| 32 } | |
| 33 fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes' | |
| 34 fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path | |
| 35 fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath | |
| 36 return fixed | |
| 37 | |
| 38 | |
| 39 def parse_cgi_response(output: t.IO[bytes]) -> t.Tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: | |
| 40 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) | |
| 41 while line := output.readline(): | |
| 42 if not line.rstrip(b'\r\n'): | |
| 43 # We've reached the end of the headers. | |
| 44 # Leave the rest in the output for later. | |
| 45 break | |
| 46 parser.feed(line) | |
| 47 msg = parser.close() | |
| 48 status = msg.get('Status', '200 OK I guess').encode('utf-8') | |
| 49 del msg['Status'] # this won't raise an exception | |
| 50 byte_headers = {k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()} | |
| 51 return status, byte_headers, output | |
| 52 | |
| 53 | |
| 54 _feeds = 0 | |
| 55 _feeder_lock = threading.Lock() | |
| 56 | |
| 57 | |
| 58 def feed_count() -> int: | |
| 59 global _feeds | |
| 60 with _feeder_lock: | |
| 61 _feeds += 1 | |
| 62 return _feeds | |
| 63 | |
| 64 | |
| 65 def handle_git_protocol( | |
| 66 original: t.Callable[..., bool], | |
| 67 req_ctx: web_inner.requestcontext, | |
| 68 request: hgreq.parsedrequest, | |
| 69 response: hgreq.wsgiresponse, | |
| 70 check_permission: PermissionCheck, | |
| 71 ) -> bool: | |
| 72 if request.headers.get(b'git-protocol'): | |
| 73 # If a request is git, we assume we should be the one handling it. | |
| 74 cgi_env = build_git_environ(req_ctx, request) | |
| 75 http_backend = req_ctx.repo.ui.configlist( | |
| 76 b'git-serve', b'http-backend', default=(b'git', b'http-backend')) | |
| 77 is_post = request.method == b'POST' | |
| 78 call = subprocess.Popen( | |
| 79 http_backend, | |
| 80 close_fds=True, | |
| 81 stdin=subprocess.PIPE if is_post else None, | |
| 82 stdout=subprocess.PIPE, | |
| 83 env=cgi_env, | |
| 84 text=False, | |
| 85 ) | |
| 86 def feed(): | |
| 87 try: | |
| 88 with call.stdin as stdin: | |
| 89 shutil.copyfileobj(request.bodyfh, stdin) | |
| 90 except (OSError, BrokenPipeError): | |
| 91 pass # Expected; this just means it's closed. | |
| 92 if is_post: | |
| 93 threading.Thread(target=feed, name=f'git-feeder-{feed_count()}').start() | |
| 94 status, headers, rest = parse_cgi_response(call.stdout) | |
| 95 response.status = status | |
| 96 for k, v in headers.items(): | |
| 97 response.headers[k] = v | |
| 98 def write_the_rest(): | |
| 99 with call, rest: | |
| 100 while more := rest.read(64 * 1024): | |
| 101 yield more | |
| 102 response.setbodygen(write_the_rest()) | |
| 103 response.sendresponse() | |
| 104 return True | |
| 105 return original(req_ctx, request, response, check_permission) | |
| 106 | |
| 107 def uisetup(_: hgui.ui) -> None: | |
| 108 extensions.wrapfunction(wireprotoserver, 'handlewsgirequest', handle_git_protocol) |
