comparison src/hggit_serve.py @ 8:fe3c9fae4d4d

Add support for pushes, and improve authentication. Now you can `git push` to a Mercurial repository! Also we check permissions much more precisely.
author Paul Fisher <paul@pfish.zone>
date Sun, 15 Feb 2026 22:26:15 -0500
parents 4f42fdbb25f2
children 5000914da3ff
comparison
equal deleted inserted replaced
7:4f42fdbb25f2 8:fe3c9fae4d4d
1 from __future__ import annotations 1 from __future__ import annotations
2 2
3 import binascii 3 import binascii
4 import email.parser 4 import email.parser
5 import email.policy 5 import email.policy
6 import os.path
7 import re 6 import re
8 import shutil 7 import shutil
9 import subprocess 8 import subprocess
10 import typing as t 9 import typing as t
11 10
12 import dulwich.refs 11 import dulwich.refs
13 import mercurial.error as hgerr 12 import mercurial.error as hgerr
13 from hggit import git_handler
14 from mercurial import extensions 14 from mercurial import extensions
15 from mercurial import registrar 15 from mercurial import registrar
16 from mercurial import wireprotoserver 16 from mercurial import wireprotoserver
17 from mercurial.thirdparty import attr
17 18
18 if t.TYPE_CHECKING: 19 if t.TYPE_CHECKING:
19 import hggit.git_handler
20 import mercurial.hgweb.hgweb_mod_inner as web_inner 20 import mercurial.hgweb.hgweb_mod_inner as web_inner
21 import mercurial.hgweb.request as hgreq 21 import mercurial.hgweb.request as hgreq
22 import mercurial.interfaces.repository as hgrepo 22 import mercurial.interfaces.repository as hgrepo
23 import mercurial.ui as hgui 23 import mercurial.ui as hgui
24 24
25 class GittyRepo(hgrepo.IRepo, t.Protocol): 25 class GittyRepo(hgrepo.IRepo, t.Protocol):
26 githandler: hggit.git_handler.GitHandler 26 githandler: git_handler.GitHandler
27 27
28 PermissionCheck = t.Callable[ 28 PermissionCheck = t.Callable[
29 [web_inner.requestcontext, hgreq.parsedrequest, bytes], 29 [web_inner.requestcontext, hgreq.parsedrequest, bytes],
30 None, 30 None,
31 ] 31 ]
32 GitPrelude = t.Sequence[bytes | str | os.PathLike]
33 32
34 33
35 def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: 34 def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]:
36 """Ensures that we have hg-git installed and active.""" 35 """Ensures that we have hg-git installed and active."""
37 return hasattr(repo, 'githandler') 36 return hasattr(repo, 'githandler')
49 fixed = { 48 fixed = {
50 k: v 49 k: v
51 for (k, v) in request.rawenv.items() 50 for (k, v) in request.rawenv.items()
52 if isinstance(v, bytes) and _CGI_VAR.match(k) 51 if isinstance(v, bytes) and _CGI_VAR.match(k)
53 } 52 }
54 fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes' 53 fixed.update(
55 fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path 54 {
56 fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath 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 )
57 return fixed 65 return fixed
58 66
59 67
60 def _parse_cgi_response( 68 def _parse_cgi_response(
61 output: t.IO[bytes], 69 output: t.IO[bytes],
75 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() 83 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
76 } 84 }
77 return status, byte_headers, output 85 return status, byte_headers, output
78 86
79 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
80 def _handle_git_protocol( 121 def _handle_git_protocol(
81 original: t.Callable[..., bool], 122 original: t.Callable[..., bool],
82 req_ctx: web_inner.requestcontext, 123 req_ctx: web_inner.requestcontext,
83 request: hgreq.parsedrequest, 124 request: hgreq.parsedrequest,
84 response: hgreq.wsgiresponse, 125 response: hgreq.wsgiresponse,
85 check_permission: PermissionCheck, 126 check_permission: PermissionCheck,
86 ) -> bool: 127 ) -> bool:
87 """Intercepts requests from Git, if needed.""" 128 """Intercepts requests from Git, if needed."""
88 repo = req_ctx.repo 129 perm = _git_service_permission(request)
89 if not _is_gitty(repo) or b'git-protocol' not in request.headers: 130 repo: hgrepo.IRepo = req_ctx.repo
90 # We only handle Git requests; everything else is normal. 131 if not perm or not _is_gitty(repo):
132 # We only handle Git requests to Gitty repos.
91 return original(req_ctx, request, response, check_permission) 133 return original(req_ctx, request, response, check_permission)
92 check_permission(req_ctx, request, b'pull') 134
93 # If a request is git, we assume we should be the one handling it. 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 )
94 cgi_env = _build_git_environ(req_ctx, request) 147 cgi_env = _build_git_environ(req_ctx, request)
95 content_length_hdr = request.headers.get(b'content-length', b'0') 148 content_length_hdr = request.headers.get(b'content-length', b'0')
96 try: 149 try:
97 content_length = int(content_length_hdr) 150 content_length = int(content_length_hdr)
98 except ValueError as ve: 151 except ValueError as ve:
99 raise hgerr.InputError( 152 raise hgerr.InputError(
100 f'Invalid content-length {content_length!r}'.encode() 153 f'Invalid content-length {content_length_hdr!r}'.encode()
101 ) from ve 154 ) from ve
102 http_backend = req_ctx.repo.ui.configlist( 155 http_backend = repo.ui.configlist(
103 b'git-serve', b'http-backend', default=(b'git', b'http-backend') 156 b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
104 ) 157 )
105 call = subprocess.Popen( 158 call = subprocess.Popen(
106 http_backend, 159 http_backend,
107 close_fds=True, 160 close_fds=True,
108 stdin=subprocess.PIPE, 161 stdin=subprocess.PIPE,
123 status, headers, rest = _parse_cgi_response(call.stdout) 176 status, headers, rest = _parse_cgi_response(call.stdout)
124 response.status = status 177 response.status = status
125 for k, v in headers.items(): 178 for k, v in headers.items():
126 response.headers[k] = v 179 response.headers[k] = v
127 180
128 def write_the_rest(): 181 def write_the_rest() -> t.Iterator[bytes]:
129 with call, rest: 182 with call, rest:
130 while more := rest.read(1024 * 1024): 183 while more := rest.read(1024 * 1024):
131 yield more 184 yield more
185 if perm == _PUSH:
186 _importing_enter(repo)
187 try:
188 gh = repo.githandler
189 gh.import_git_objects(
190 b'git-push', remote_names=(), refs=gh.git.refs.as_dict()
191 )
192 finally:
193 _importing_exit(repo)
132 194
133 response.setbodygen(write_the_rest()) 195 response.setbodygen(write_the_rest())
134 response.sendresponse() 196 response.sendresponse()
135 return True 197 return True
136 198
137 199
200 #
201 # Stuff so that we don't try to export revisions while we're importing.
202 #
203
204 _ILEVEL_ATTR = '@hggit_import_level'
205 """An attribute that tracks how many "levels deep" we are into importing.
206
207 We set this on the repository object when we're importing and remove it
208 when we're done. It's not just a bool in case somebody sets up some crazy
209 recursive hook situation where we start importing inside another import.
210 """
211
212
213 def _importing_enter(repo: hgrepo.IRepo) -> None:
214 """Call this before you start importing from Git."""
215 level = getattr(repo, _ILEVEL_ATTR, 0) + 1
216 setattr(repo, _ILEVEL_ATTR, level)
217
218
219 def _is_importing(repo: hgrepo.IRepo) -> None:
220 """Call this to check if you're currently importing."""
221 return hasattr(repo, _ILEVEL_ATTR)
222
223
224 def _importing_exit(repo: hgrepo.IRepo) -> None:
225 """Call this after you finish importing from Git."""
226 level = getattr(repo, _ILEVEL_ATTR) - 1
227 if level:
228 setattr(repo, _ILEVEL_ATTR, level)
229 else:
230 delattr(repo, _ILEVEL_ATTR)
231
232
233 #
234 # Export handling.
235 #
236
237
138 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: 238 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
139 """Removes all refs from the Git repository.""" 239 """Removes all refs from the Git repository."""
140 for ref in refs.allkeys():
141 refs.remove_if_equals(ref, None)
142 240
143 241
144 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None: 242 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
145 """Creates a HEAD reference in Git referring to the current HEAD.""" 243 """Creates a HEAD reference in Git referring to the current HEAD."""
146 # By default, we use '@', since that's what will be auto checked out. 244 # By default, we use '@', since that's what will be auto checked out.
158 # We can't export this ref to Git. Give up. 256 # We can't export this ref to Git. Give up.
159 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode()) 257 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode())
160 return 258 return
161 try: 259 try:
162 # Maybe this is a real bookmark? 260 # Maybe this is a real bookmark?
163 hgsha = repo._bookmarks[current] 261 hgnode = repo._bookmarks[current]
164 except KeyError: 262 except KeyError:
165 # Not a real bookmark. Assume we want the tip of the current branch. 263 # Not a real bookmark. Assume we want the tip of the current branch.
166 branch = repo.dirstate.branch() 264 branch = repo.dirstate.branch()
167 try: 265 try:
168 tip = repo.branchtip(branch) 266 hgnode = repo.branchtip(branch)
169 except hgerr.RepoLookupError: 267 except hgerr.RepoLookupError:
170 # This branch somehow doesn't exist??? 268 # This branch somehow doesn't exist???
171 ui.warn(f"{branch} doesn't seem to exist?".encode()) 269 ui.warn(f"{branch!r} doesn't seem to exist?".encode())
172 return 270 return
173 hgsha = binascii.hexlify(tip) 271 hgsha = binascii.hexlify(hgnode)
174 gitsha = repo.githandler.map_git_get(hgsha) 272 gitsha = repo.githandler.map_git_get(hgsha)
175 if not gitsha: 273 if not gitsha:
176 # No Git SHA to match this Hg sha. Give up. 274 # No Git SHA to match this Hg sha. Give up.
177 ui.warn(f'revision {hgsha} was not exported to Git'.encode()) 275 ui.warn(f'revision {hgsha!r} was not exported to Git'.encode())
178 return 276 return
179 refs = repo.githandler.git.refs 277 refs = repo.githandler.git.refs
180 refs.add_packed_refs({git_branch: gitsha}) 278 refs.add_packed_refs({git_branch: gitsha})
181 refs.set_symbolic_ref(b'HEAD', git_branch) 279 refs.set_symbolic_ref(b'HEAD', git_branch)
182 280
183 281
184 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: 282 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
185 """Exports to Git and sets up for serving.""" 283 """Exports to Git and sets up for serving. See ``_fix_refs``."""
186 if not _is_gitty(repo): 284 if not _is_gitty(repo):
187 return 285 return
188 _fix_refs(ui, repo) 286 _fix_refs(ui, repo)
189 287
190 288
191 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None: 289 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None:
192 """After a git export, fix up the refs.""" 290 """After a git export, fix up the refs.
193 _clean_all_refs(repo.githandler.git.refs) 291
292 This ensures that there are no leftover refs from older, removed bookmarks
293 and that there is a proper HEAD set so that cloning works.
294 """
295 refs = repo.githandler.git.refs
296 # dump to allkeys so we explicitly are iterating over a snapshot
297 # and not over something while we mutate
298 for ref in refs.allkeys():
299 refs.remove_if_equals(ref, None)
194 repo.githandler.export_hg_tags() 300 repo.githandler.export_hg_tags()
195 repo.githandler.update_references() 301 repo.githandler.update_references()
196 default_branch_name = ui.config( 302 default_branch_name = ui.config(
197 b'hggit-serve', b'default-branch', b'default' 303 b'hggit-serve', b'default-branch', b'default'
198 ) 304 )
199 _set_head(ui, repo, default_branch_name) 305 _set_head(ui, repo, default_branch_name)
200 306
201 307
202 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: 308 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
309 """Maybe exports the repository to get after we get new revs."""
203 if not _is_gitty(repo): 310 if not _is_gitty(repo):
204 return 311 return
205 auto_export = ui.config(b'hggit-serve', b'auto-export') 312 auto_export = ui.config(b'hggit-serve', b'auto-export')
206 if auto_export == b'never': 313 if auto_export == b'never':
207 return 314 return
208 if auto_export == b'always' or os.path.isdir(repo.githandler.gitdir): 315 if auto_export == b'always' or git_handler.has_gitrepo(repo):
316 if _is_importing(repo):
317 ui.note(b'currently importing revs from git; not exporting\n')
318 return
209 repo.githandler.export_commits() 319 repo.githandler.export_commits()
210 _fix_refs(ui, repo) 320 _fix_refs(ui, repo)
211 321
212 322
323 #
213 # Interfacing with Mercurial 324 # Interfacing with Mercurial
214 325 #
215 __version__ = '0.1.5' 326
327 __version__ = '0.2.0'
216 testedwith = b'7.1 7.2' 328 testedwith = b'7.1 7.2'
329 minimumhgversion = b'7.1'
217 330
218 cmdtable: dict[bytes, object] = {} 331 cmdtable: dict[bytes, object] = {}
219 332
220 command = registrar.command(cmdtable) 333 command = registrar.command(cmdtable)
221 334
225 wireprotoserver, 'handlewsgirequest', _handle_git_protocol 338 wireprotoserver, 'handlewsgirequest', _handle_git_protocol
226 ) 339 )
227 340
228 341
229 def uipopulate(ui: hgui.ui) -> None: 342 def uipopulate(ui: hgui.ui) -> None:
343 # Fix up our tags after a Git export.
230 ui.setconfig( 344 ui.setconfig(
231 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook 345 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook
232 ) 346 )
347 # Whenever we get new revisions, export them to the Git repository.
233 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook) 348 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook)
349 # Don't step on ourselves when importing data from Git.
350 ui.setconfig(
351 b'hooks',
352 b'pre-git-import.__gitserve_suppress_export__',
353 lambda _, repo, **__: _importing_enter(repo),
354 )
355 ui.setconfig(
356 b'hooks',
357 b'post-git-import.__gitserve_suppress_export__',
358 lambda _, repo, **__: _importing_exit(repo),
359 )
234 360
235 361
236 __all__ = ( 362 __all__ = (
237 '__version__', 363 '__version__',
238 'cmdtable', 364 'cmdtable',
239 'command', 365 'command',
366 'minimumhgversion',
240 'testedwith', 367 'testedwith',
241 'uipopulate', 368 'uipopulate',
242 'uisetup', 369 'uisetup',
243 ) 370 )