Mercurial > hg-git-serve
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 ) |
