Mercurial > hg-git-serve
changeset 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 |
| files | pyproject.toml src/hggit_serve.py |
| diffstat | 2 files changed, 156 insertions(+), 29 deletions(-) [+] |
line wrap: on
line diff
--- a/pyproject.toml Sun Feb 15 01:49:42 2026 -0500 +++ b/pyproject.toml Sun Feb 15 22:26:15 2026 -0500 @@ -25,7 +25,7 @@ ] dependencies = [ 'dulwich', - 'mercurial', + 'mercurial >= 7.1', 'hg-git', ]
--- a/src/hggit_serve.py Sun Feb 15 01:49:42 2026 -0500 +++ b/src/hggit_serve.py Sun Feb 15 22:26:15 2026 -0500 @@ -3,7 +3,6 @@ import binascii import email.parser import email.policy -import os.path import re import shutil import subprocess @@ -11,25 +10,25 @@ import dulwich.refs import mercurial.error as hgerr +from hggit import git_handler from mercurial import extensions from mercurial import registrar from mercurial import wireprotoserver +from mercurial.thirdparty import attr if t.TYPE_CHECKING: - import hggit.git_handler import mercurial.hgweb.hgweb_mod_inner as web_inner import mercurial.hgweb.request as hgreq import mercurial.interfaces.repository as hgrepo import mercurial.ui as hgui class GittyRepo(hgrepo.IRepo, t.Protocol): - githandler: hggit.git_handler.GitHandler + githandler: git_handler.GitHandler PermissionCheck = t.Callable[ [web_inner.requestcontext, hgreq.parsedrequest, bytes], None, ] - GitPrelude = t.Sequence[bytes | str | os.PathLike] def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: @@ -51,9 +50,18 @@ for (k, v) in request.rawenv.items() if isinstance(v, bytes) and _CGI_VAR.match(k) } - fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes' - fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path - fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath + fixed.update( + { + b'GIT_HTTP_EXPORT_ALL': b'yes', + b'GIT_PROJECT_ROOT': req_ctx.repo.path, + b'PATH_INFO': b'/git/' + request.dispatchpath, + # Since Mercurial is taking care of authorization checking, + # we tell Git to always allow push. + b'GIT_CONFIG_COUNT': b'1', + b'GIT_CONFIG_KEY_0': b'http.receivepack', + b'GIT_CONFIG_VALUE_0': b'true', + } + ) return fixed @@ -77,6 +85,39 @@ return status, byte_headers, output +_PULL = b'pull' +_PUSH = b'push' + +_SERVICE_PERMISSIONS = { + b'git-upload-pack': _PULL, + b'git-receive-pack': _PUSH, +} +"""The Mercurial permission corresponding to each Git action. + +These seem backwards because the direction of up/download is relative to +the server, so when the client pulls, the server is *uploading*, +and when the client pushes, the server is *downloading*. +""" + + +def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None: + """Figures out what Mercurial permission corresponds to a request from Git. + + If the request is a supported Git action, returns the permission it needs. + If the request is not a Git action, returns None. + """ + if perm := _SERVICE_PERMISSIONS.get(request.dispatchpath): + return perm + if request.dispatchpath != b'info/refs': + return None + qs = request.querystring + service = qs.removeprefix(b'service=') + if qs == service: + # Nothing was stripped. + return None + return _SERVICE_PERMISSIONS.get(service) + + def _handle_git_protocol( original: t.Callable[..., bool], req_ctx: web_inner.requestcontext, @@ -85,22 +126,34 @@ check_permission: PermissionCheck, ) -> bool: """Intercepts requests from Git, if needed.""" - repo = req_ctx.repo - if not _is_gitty(repo) or b'git-protocol' not in request.headers: - # We only handle Git requests; everything else is normal. + perm = _git_service_permission(request) + repo: hgrepo.IRepo = req_ctx.repo + if not perm or not _is_gitty(repo): + # We only handle Git requests to Gitty repos. return original(req_ctx, request, response, check_permission) - check_permission(req_ctx, request, b'pull') - # If a request is git, we assume we should be the one handling it. + + # Permission workaround: Mercurial requires POSTs for push, + # but the advertisement request from Git will be a GET. + # We just lie to Mercurial about what we're doing. + check_permission( + req_ctx, + ( + attr.evolve(req_ctx.req, method=b'POST') + if perm == _PUSH + else req_ctx.req + ), + perm, + ) cgi_env = _build_git_environ(req_ctx, request) content_length_hdr = request.headers.get(b'content-length', b'0') try: content_length = int(content_length_hdr) except ValueError as ve: raise hgerr.InputError( - f'Invalid content-length {content_length!r}'.encode() + f'Invalid content-length {content_length_hdr!r}'.encode() ) from ve - http_backend = req_ctx.repo.ui.configlist( - b'git-serve', b'http-backend', default=(b'git', b'http-backend') + http_backend = repo.ui.configlist( + b'hggit-serve', b'http-backend', default=(b'git', b'http-backend') ) call = subprocess.Popen( http_backend, @@ -125,20 +178,65 @@ for k, v in headers.items(): response.headers[k] = v - def write_the_rest(): + def write_the_rest() -> t.Iterator[bytes]: with call, rest: while more := rest.read(1024 * 1024): yield more + if perm == _PUSH: + _importing_enter(repo) + try: + gh = repo.githandler + gh.import_git_objects( + b'git-push', remote_names=(), refs=gh.git.refs.as_dict() + ) + finally: + _importing_exit(repo) response.setbodygen(write_the_rest()) response.sendresponse() return True +# +# Stuff so that we don't try to export revisions while we're importing. +# + +_ILEVEL_ATTR = '@hggit_import_level' +"""An attribute that tracks how many "levels deep" we are into importing. + +We set this on the repository object when we're importing and remove it +when we're done. It's not just a bool in case somebody sets up some crazy +recursive hook situation where we start importing inside another import. +""" + + +def _importing_enter(repo: hgrepo.IRepo) -> None: + """Call this before you start importing from Git.""" + level = getattr(repo, _ILEVEL_ATTR, 0) + 1 + setattr(repo, _ILEVEL_ATTR, level) + + +def _is_importing(repo: hgrepo.IRepo) -> None: + """Call this to check if you're currently importing.""" + return hasattr(repo, _ILEVEL_ATTR) + + +def _importing_exit(repo: hgrepo.IRepo) -> None: + """Call this after you finish importing from Git.""" + level = getattr(repo, _ILEVEL_ATTR) - 1 + if level: + setattr(repo, _ILEVEL_ATTR, level) + else: + delattr(repo, _ILEVEL_ATTR) + + +# +# Export handling. +# + + def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: """Removes all refs from the Git repository.""" - for ref in refs.allkeys(): - refs.remove_if_equals(ref, None) def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None: @@ -160,21 +258,21 @@ return try: # Maybe this is a real bookmark? - hgsha = repo._bookmarks[current] + hgnode = repo._bookmarks[current] except KeyError: # Not a real bookmark. Assume we want the tip of the current branch. branch = repo.dirstate.branch() try: - tip = repo.branchtip(branch) + hgnode = repo.branchtip(branch) except hgerr.RepoLookupError: # This branch somehow doesn't exist??? - ui.warn(f"{branch} doesn't seem to exist?".encode()) + ui.warn(f"{branch!r} doesn't seem to exist?".encode()) return - hgsha = binascii.hexlify(tip) + hgsha = binascii.hexlify(hgnode) gitsha = repo.githandler.map_git_get(hgsha) if not gitsha: # No Git SHA to match this Hg sha. Give up. - ui.warn(f'revision {hgsha} was not exported to Git'.encode()) + ui.warn(f'revision {hgsha!r} was not exported to Git'.encode()) return refs = repo.githandler.git.refs refs.add_packed_refs({git_branch: gitsha}) @@ -182,15 +280,23 @@ def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: - """Exports to Git and sets up for serving.""" + """Exports to Git and sets up for serving. See ``_fix_refs``.""" if not _is_gitty(repo): return _fix_refs(ui, repo) def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None: - """After a git export, fix up the refs.""" - _clean_all_refs(repo.githandler.git.refs) + """After a git export, fix up the refs. + + This ensures that there are no leftover refs from older, removed bookmarks + and that there is a proper HEAD set so that cloning works. + """ + refs = repo.githandler.git.refs + # dump to allkeys so we explicitly are iterating over a snapshot + # and not over something while we mutate + for ref in refs.allkeys(): + refs.remove_if_equals(ref, None) repo.githandler.export_hg_tags() repo.githandler.update_references() default_branch_name = ui.config( @@ -200,20 +306,27 @@ def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: + """Maybe exports the repository to get after we get new revs.""" if not _is_gitty(repo): return auto_export = ui.config(b'hggit-serve', b'auto-export') if auto_export == b'never': return - if auto_export == b'always' or os.path.isdir(repo.githandler.gitdir): + if auto_export == b'always' or git_handler.has_gitrepo(repo): + if _is_importing(repo): + ui.note(b'currently importing revs from git; not exporting\n') + return repo.githandler.export_commits() _fix_refs(ui, repo) +# # Interfacing with Mercurial +# -__version__ = '0.1.5' +__version__ = '0.2.0' testedwith = b'7.1 7.2' +minimumhgversion = b'7.1' cmdtable: dict[bytes, object] = {} @@ -227,16 +340,30 @@ def uipopulate(ui: hgui.ui) -> None: + # Fix up our tags after a Git export. ui.setconfig( b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook ) + # Whenever we get new revisions, export them to the Git repository. ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook) + # Don't step on ourselves when importing data from Git. + ui.setconfig( + b'hooks', + b'pre-git-import.__gitserve_suppress_export__', + lambda _, repo, **__: _importing_enter(repo), + ) + ui.setconfig( + b'hooks', + b'post-git-import.__gitserve_suppress_export__', + lambda _, repo, **__: _importing_exit(repo), + ) __all__ = ( '__version__', 'cmdtable', 'command', + 'minimumhgversion', 'testedwith', 'uipopulate', 'uisetup',
