comparison 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
comparison
equal deleted inserted replaced
11:ce204bcc4e04 12:f630d9904ea7
1 from __future__ import annotations
2
3 import email.parser
4 import email.policy
5 import re
6 import shutil
7 import subprocess
8 import typing as t
9
10 from mercurial import extensions
11 from mercurial import wireprotoserver
12 from mercurial.thirdparty import attr
13
14 from . import _export as xp
15
16 if t.TYPE_CHECKING:
17 import mercurial.hgweb.hgweb_mod_inner as web_inner
18 import mercurial.hgweb.request as hgreq
19 import mercurial.interfaces.repository as hgrepo
20 import mercurial.ui as hgui
21
22 PermissionCheck = t.Callable[
23 [web_inner.requestcontext, hgreq.parsedrequest, bytes],
24 None,
25 ]
26
27
28 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
29 """Environment variables that we need to pass to git-as-cgi."""
30
31
32 def _build_git_environ(
33 req_ctx: web_inner.requestcontext,
34 request: hgreq.parsedrequest,
35 ) -> dict[bytes, bytes]:
36 """Builds the environment to be sent to Git to serve HTTP."""
37 fixed = {
38 k: v
39 for (k, v) in request.rawenv.items()
40 if isinstance(v, bytes) and _CGI_VAR.match(k)
41 }
42 fixed.update(
43 {
44 b'GIT_HTTP_EXPORT_ALL': b'yes',
45 b'GIT_PROJECT_ROOT': req_ctx.repo.path,
46 b'PATH_INFO': b'/git/' + request.dispatchpath,
47 # Since Mercurial is taking care of authorization checking,
48 # we tell Git to always allow push.
49 b'GIT_CONFIG_COUNT': b'1',
50 b'GIT_CONFIG_KEY_0': b'http.receivepack',
51 b'GIT_CONFIG_VALUE_0': b'true',
52 }
53 )
54 return fixed
55
56
57 def _parse_cgi_response(
58 output: t.IO[bytes],
59 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
60 """Parses a CGI response into a status, headers, and everyhting else."""
61 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
62 while line := output.readline():
63 if not line.rstrip(b'\r\n'):
64 # We've reached the end of the headers.
65 # Leave the rest in the output for later.
66 break
67 parser.feed(line)
68 msg = parser.close()
69 status = msg.get('Status', '200 OK I guess').encode('utf-8')
70 del msg['Status'] # this won't raise an exception
71 byte_headers = {
72 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
73 }
74 return status, byte_headers, output
75
76
77 def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None:
78 """Figures out what Mercurial permission corresponds to a request from Git.
79
80 If the request is a supported Git action, returns the permission it needs.
81 If the request is not a Git action, returns None.
82 """
83 if perm := xp.SERVICE_PERMISSIONS.get(request.dispatchpath):
84 return perm
85 if request.dispatchpath != b'info/refs':
86 return None
87 qs = request.querystring
88 service = qs.removeprefix(b'service=')
89 if qs == service:
90 # Nothing was stripped.
91 return None
92 return xp.SERVICE_PERMISSIONS.get(service)
93
94
95 def _handle_git_protocol(
96 original: t.Callable[..., bool],
97 req_ctx: web_inner.requestcontext,
98 request: hgreq.parsedrequest,
99 response: hgreq.wsgiresponse,
100 check_permission: PermissionCheck,
101 ) -> bool:
102 """Intercepts requests from Git, if needed."""
103 perm = _git_service_permission(request)
104 repo: hgrepo.IRepo = req_ctx.repo
105 if not perm or not xp.is_gitty(repo):
106 # We only handle Git requests to Gitty repos.
107 return original(req_ctx, request, response, check_permission)
108
109 # Permission workaround: Mercurial requires POSTs for push,
110 # but the advertisement request from Git will be a GET.
111 # We just lie to Mercurial about what we're doing.
112 check_permission(
113 req_ctx,
114 (
115 attr.evolve(req_ctx.req, method=b'POST')
116 if perm == xp.PUSH
117 else req_ctx.req
118 ),
119 perm,
120 )
121 cgi_env = _build_git_environ(req_ctx, request)
122 http_backend = repo.ui.configlist(
123 b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
124 )
125 call = subprocess.Popen(
126 http_backend,
127 close_fds=True,
128 stdin=subprocess.PIPE,
129 stdout=subprocess.PIPE,
130 stderr=subprocess.DEVNULL,
131 env=cgi_env,
132 text=False,
133 )
134 assert call.stdout
135 assert call.stdin
136 # Git will not start writing output until stdin is fully closed.
137 with call.stdin:
138 # This is how we know if there's anything to read from bodyfh.
139 # If we try to read from bodyfh on a request with no content,
140 # it hangs forever.
141 if b'CONTENT_LENGTH' in request.rawenv:
142 shutil.copyfileobj(request.bodyfh, call.stdin)
143
144 status, headers, rest = _parse_cgi_response(call.stdout)
145 response.status = status
146 for k, v in headers.items():
147 response.headers[k] = v
148
149 def write_the_rest() -> t.Iterator[bytes]:
150 with call, rest:
151 # if it's good enough for shutil it's good enough for me
152 # technically not in the docs but everybody it
153 bs = shutil.COPY_BUFSIZE # type: ignore[attr-defined]
154 while more := rest.read(bs):
155 yield more
156 if perm == xp.PUSH:
157 # If we pushed, we need to import any new refs back into Mercurial.
158 xp.importing_enter(repo)
159 try:
160 gh = repo.githandler
161 gh.import_git_objects(
162 b'git-push', remote_names=(), refs=gh.git.refs.as_dict()
163 )
164 finally:
165 xp.importing_exit(repo)
166
167 response.setbodygen(write_the_rest())
168 response.sendresponse()
169 return True
170
171
172 #
173 # Interfacing with Mercurial
174 #
175
176
177 def uisetup(_: hgui.ui) -> None:
178 extensions.wrapfunction(
179 wireprotoserver, 'handlewsgirequest', _handle_git_protocol
180 )