comparison src/hgext3rd/hggit_serve.py @ 11:ce204bcc4e04

Move to hgext3rd/hggit_serve.py.
author Paul Fisher <paul@pfish.zone>
date Wed, 18 Feb 2026 14:45:19 -0500
parents src/hggit_serve.py@c2ae14c981e1
children
comparison
equal deleted inserted replaced
10:c2ae14c981e1 11:ce204bcc4e04
1 from __future__ import annotations
2
3 import binascii
4 import email.parser
5 import email.policy
6 import re
7 import shutil
8 import subprocess
9 import typing as t
10
11 import dulwich.refs
12 import mercurial.error as hgerr
13 from hggit import git_handler
14 from mercurial import extensions
15 from mercurial import registrar
16 from mercurial import wireprotoserver
17 from mercurial.thirdparty import attr
18
19 if t.TYPE_CHECKING:
20 import mercurial.hgweb.hgweb_mod_inner as web_inner
21 import mercurial.hgweb.request as hgreq
22 import mercurial.interfaces.repository as hgrepo
23 import mercurial.ui as hgui
24
25 class GittyRepo(hgrepo.IRepo, t.Protocol):
26 githandler: git_handler.GitHandler
27
28 PermissionCheck = t.Callable[
29 [web_inner.requestcontext, hgreq.parsedrequest, bytes],
30 None,
31 ]
32
33
34 def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]:
35 """Ensures that we have hg-git installed and active."""
36 return hasattr(repo, 'githandler')
37
38
39 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
40 """Environment variables that we need to pass to git-as-cgi."""
41
42
43 def _build_git_environ(
44 req_ctx: web_inner.requestcontext,
45 request: hgreq.parsedrequest,
46 ) -> dict[bytes, bytes]:
47 """Builds the environment to be sent to Git to serve HTTP."""
48 fixed = {
49 k: v
50 for (k, v) in request.rawenv.items()
51 if isinstance(v, bytes) and _CGI_VAR.match(k)
52 }
53 fixed.update(
54 {
55 b'GIT_HTTP_EXPORT_ALL': b'yes',
56 b'GIT_PROJECT_ROOT': req_ctx.repo.path,
57 b'PATH_INFO': b'/git/' + request.dispatchpath,
58 # Since Mercurial is taking care of authorization checking,
59 # we tell Git to always allow push.
60 b'GIT_CONFIG_COUNT': b'1',
61 b'GIT_CONFIG_KEY_0': b'http.receivepack',
62 b'GIT_CONFIG_VALUE_0': b'true',
63 }
64 )
65 return fixed
66
67
68 def _parse_cgi_response(
69 output: t.IO[bytes],
70 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
71 """Parses a CGI response into a status, headers, and everyhting else."""
72 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
73 while line := output.readline():
74 if not line.rstrip(b'\r\n'):
75 # We've reached the end of the headers.
76 # Leave the rest in the output for later.
77 break
78 parser.feed(line)
79 msg = parser.close()
80 status = msg.get('Status', '200 OK I guess').encode('utf-8')
81 del msg['Status'] # this won't raise an exception
82 byte_headers = {
83 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
84 }
85 return status, byte_headers, output
86
87
88 _PULL = b'pull'
89 _PUSH = b'push'
90
91 _SERVICE_PERMISSIONS = {
92 b'git-upload-pack': _PULL,
93 b'git-receive-pack': _PUSH,
94 }
95 """The Mercurial permission corresponding to each Git action.
96
97 These seem backwards because the direction of up/download is relative to
98 the server, so when the client pulls, the server is *uploading*,
99 and when the client pushes, the server is *downloading*.
100 """
101
102
103 def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None:
104 """Figures out what Mercurial permission corresponds to a request from Git.
105
106 If the request is a supported Git action, returns the permission it needs.
107 If the request is not a Git action, returns None.
108 """
109 if perm := _SERVICE_PERMISSIONS.get(request.dispatchpath):
110 return perm
111 if request.dispatchpath != b'info/refs':
112 return None
113 qs = request.querystring
114 service = qs.removeprefix(b'service=')
115 if qs == service:
116 # Nothing was stripped.
117 return None
118 return _SERVICE_PERMISSIONS.get(service)
119
120
121 def _handle_git_protocol(
122 original: t.Callable[..., bool],
123 req_ctx: web_inner.requestcontext,
124 request: hgreq.parsedrequest,
125 response: hgreq.wsgiresponse,
126 check_permission: PermissionCheck,
127 ) -> bool:
128 """Intercepts requests from Git, if needed."""
129 perm = _git_service_permission(request)
130 repo: hgrepo.IRepo = req_ctx.repo
131 if not perm or not _is_gitty(repo):
132 # We only handle Git requests to Gitty repos.
133 return original(req_ctx, request, response, check_permission)
134
135 # Permission workaround: Mercurial requires POSTs for push,
136 # but the advertisement request from Git will be a GET.
137 # We just lie to Mercurial about what we're doing.
138 check_permission(
139 req_ctx,
140 (
141 attr.evolve(req_ctx.req, method=b'POST')
142 if perm == _PUSH
143 else req_ctx.req
144 ),
145 perm,
146 )
147 cgi_env = _build_git_environ(req_ctx, request)
148 http_backend = repo.ui.configlist(
149 b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
150 )
151 call = subprocess.Popen(
152 http_backend,
153 close_fds=True,
154 stdin=subprocess.PIPE,
155 stdout=subprocess.PIPE,
156 stderr=subprocess.DEVNULL,
157 env=cgi_env,
158 text=False,
159 )
160 assert call.stdout
161 assert call.stdin
162 # Git will not start writing output until stdin is fully closed.
163 with call.stdin:
164 # This is how we know if there's anything to read from bodyfh.
165 # If we try to read from bodyfh on a request with no content,
166 # it hangs forever.
167 if b'CONTENT_LENGTH' in request.rawenv:
168 shutil.copyfileobj(request.bodyfh, call.stdin)
169
170 status, headers, rest = _parse_cgi_response(call.stdout)
171 response.status = status
172 for k, v in headers.items():
173 response.headers[k] = v
174
175 def write_the_rest() -> t.Iterator[bytes]:
176 with call, rest:
177 # if it's good enough for shutil it's good enough for me
178 # technically not in the docs but everybody it
179 bs = shutil.COPY_BUFSIZE # type: ignore[attr-defined]
180 while more := rest.read(bs):
181 yield more
182 if perm == _PUSH:
183 _importing_enter(repo)
184 try:
185 gh = repo.githandler
186 gh.import_git_objects(
187 b'git-push', remote_names=(), refs=gh.git.refs.as_dict()
188 )
189 finally:
190 _importing_exit(repo)
191
192 response.setbodygen(write_the_rest())
193 response.sendresponse()
194 return True
195
196
197 #
198 # Stuff so that we don't try to export revisions while we're importing.
199 #
200
201 _ILEVEL_ATTR = '@hggit_import_level'
202 """An attribute that tracks how many "levels deep" we are into importing.
203
204 We set this on the repository object when we're importing and remove it
205 when we're done. It's not just a bool in case somebody sets up some crazy
206 recursive hook situation where we start importing inside another import.
207 """
208
209
210 def _importing_enter(repo: hgrepo.IRepo) -> None:
211 """Call this before you start importing from Git."""
212 level = getattr(repo, _ILEVEL_ATTR, 0) + 1
213 setattr(repo, _ILEVEL_ATTR, level)
214
215
216 def _is_importing(repo: hgrepo.IRepo) -> bool:
217 """Call this to check if you're currently importing."""
218 return hasattr(repo, _ILEVEL_ATTR)
219
220
221 def _importing_exit(repo: hgrepo.IRepo) -> None:
222 """Call this after you finish importing from Git."""
223 level = getattr(repo, _ILEVEL_ATTR) - 1
224 if level:
225 setattr(repo, _ILEVEL_ATTR, level)
226 else:
227 delattr(repo, _ILEVEL_ATTR)
228
229
230 #
231 # Export handling.
232 #
233
234
235 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
236 """Removes all refs from the Git repository."""
237
238
239 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
240 """Creates a HEAD reference in Git referring to the current HEAD."""
241 # By default, we use '@', since that's what will be auto checked out.
242 current = b'@'
243 if current not in repo._bookmarks:
244 current = repo._bookmarks.active or current
245
246 # We'll be moving this (possibly fake) bookmark into Git.
247 git_current = current
248 if current == b'@':
249 # @ is a special keyword in Git, so we can't use it as a bookmark.
250 git_current = at_name
251 git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current
252 if not dulwich.refs.check_ref_format(git_branch):
253 # We can't export this ref to Git. Give up.
254 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode())
255 return
256 try:
257 # Maybe this is a real bookmark?
258 hgnode = repo._bookmarks[current]
259 except KeyError:
260 # Not a real bookmark. Assume we want the tip of the current branch.
261 branch = repo.dirstate.branch()
262 try:
263 hgnode = repo.branchtip(branch)
264 except hgerr.RepoLookupError:
265 # This branch somehow doesn't exist???
266 ui.warn(f"{branch!r} doesn't seem to exist?".encode())
267 return
268 hgsha = binascii.hexlify(hgnode)
269 gitsha = repo.githandler.map_git_get(hgsha)
270 if not gitsha:
271 # No Git SHA to match this Hg sha. Give up.
272 ui.warn(f'revision {hgsha!r} was not exported to Git'.encode())
273 return
274 refs = repo.githandler.git.refs
275 refs.add_packed_refs({git_branch: gitsha})
276 refs.set_symbolic_ref(b'HEAD', git_branch)
277
278
279 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
280 """Exports to Git and sets up for serving. See ``_fix_refs``."""
281 if not _is_gitty(repo):
282 return
283 _fix_refs(ui, repo)
284
285
286 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None:
287 """After a git export, fix up the refs.
288
289 This ensures that there are no leftover refs from older, removed bookmarks
290 and that there is a proper HEAD set so that cloning works.
291 """
292 refs = repo.githandler.git.refs
293 # dump to allkeys so we explicitly are iterating over a snapshot
294 # and not over something while we mutate
295 for ref in refs.allkeys():
296 refs.remove_if_equals(ref, None)
297 repo.githandler.export_hg_tags()
298 repo.githandler.update_references()
299 default_branch_name = ui.config(
300 b'hggit-serve', b'default-branch', b'default'
301 )
302 _set_head(ui, repo, default_branch_name)
303
304
305 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
306 """Maybe exports the repository to get after we get new revs."""
307 if not _is_gitty(repo):
308 return
309 auto_export = ui.config(b'hggit-serve', b'auto-export')
310 if auto_export == b'never':
311 return
312 if auto_export == b'always' or git_handler.has_gitrepo(repo):
313 if _is_importing(repo):
314 ui.note(b'currently importing revs from git; not exporting\n')
315 return
316 repo.githandler.export_commits()
317 _fix_refs(ui, repo)
318
319
320 #
321 # Interfacing with Mercurial
322 #
323
324 __version__ = '0.2.0'
325 testedwith = b'7.1 7.2'
326 minimumhgversion = b'7.1'
327
328 cmdtable: dict[bytes, object] = {}
329
330 command = registrar.command(cmdtable)
331
332
333 def uisetup(_: hgui.ui) -> None:
334 extensions.wrapfunction(
335 wireprotoserver, 'handlewsgirequest', _handle_git_protocol
336 )
337
338
339 def uipopulate(ui: hgui.ui) -> None:
340 # Fix up our tags after a Git export.
341 ui.setconfig(
342 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook
343 )
344 # Whenever we get new revisions, export them to the Git repository.
345 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook)
346 # Don't step on ourselves when importing data from Git.
347 ui.setconfig(
348 b'hooks',
349 b'pre-git-import.__gitserve_suppress_export__',
350 lambda _, repo, **__: _importing_enter(repo),
351 )
352 ui.setconfig(
353 b'hooks',
354 b'post-git-import.__gitserve_suppress_export__',
355 lambda _, repo, **__: _importing_exit(repo),
356 )
357
358
359 __all__ = (
360 '__version__',
361 'cmdtable',
362 'command',
363 'minimumhgversion',
364 'testedwith',
365 'uipopulate',
366 'uisetup',
367 )