changeset 12:f630d9904ea7

Reorganize project into multiple files.
author Paul Fisher <paul@pfish.zone>
date Wed, 18 Feb 2026 16:17:05 -0500
parents ce204bcc4e04
children 00bdfac5416c
files .hgignore pyproject.toml src/hgext3rd/hggit_serve.py src/hgext3rd/hggit_serve/__init__.py src/hgext3rd/hggit_serve/_export.py src/hgext3rd/hggit_serve/_http.py
diffstat 6 files changed, 406 insertions(+), 371 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Wed Feb 18 14:45:19 2026 -0500
+++ b/.hgignore	Wed Feb 18 16:17:05 2026 -0500
@@ -1,9 +1,9 @@
 syntax: rootglob
 .venv/
-.mypy_cache/
 .ruff_cache/
 # Default ignored files
 .idea/
 
 syntax: glob
-__pycache__/
\ No newline at end of file
+__pycache__/
+.mypy_cache/
--- a/pyproject.toml	Wed Feb 18 14:45:19 2026 -0500
+++ b/pyproject.toml	Wed Feb 18 16:17:05 2026 -0500
@@ -30,10 +30,10 @@
 ]
 
 [tool.hatch.build.targets.wheel]
-packages = ['src/hgext3rd/hggit_serve.py']
+packages = ['src/hgext3rd/hggit_serve']
 
 [tool.hatch.version]
-path = "src/hgext3rd/hggit_serve.py"
+path = "src/hgext3rd/hggit_serve/__init__.py"
 
 [tool.hatch.envs.types]
 extra-dependencies = [
--- a/src/hgext3rd/hggit_serve.py	Wed Feb 18 14:45:19 2026 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,367 +0,0 @@
-from __future__ import annotations
-
-import binascii
-import email.parser
-import email.policy
-import re
-import shutil
-import subprocess
-import typing as t
-
-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 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: git_handler.GitHandler
-
-    PermissionCheck = t.Callable[
-        [web_inner.requestcontext, hgreq.parsedrequest, bytes],
-        None,
-    ]
-
-
-def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]:
-    """Ensures that we have hg-git installed and active."""
-    return hasattr(repo, 'githandler')
-
-
-_CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
-"""Environment variables that we need to pass to git-as-cgi."""
-
-
-def _build_git_environ(
-    req_ctx: web_inner.requestcontext,
-    request: hgreq.parsedrequest,
-) -> dict[bytes, bytes]:
-    """Builds the environment to be sent to Git to serve HTTP."""
-    fixed = {
-        k: v
-        for (k, v) in request.rawenv.items()
-        if isinstance(v, bytes) and _CGI_VAR.match(k)
-    }
-    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
-
-
-def _parse_cgi_response(
-    output: t.IO[bytes],
-) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
-    """Parses a CGI response into a status, headers, and everyhting else."""
-    parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
-    while line := output.readline():
-        if not line.rstrip(b'\r\n'):
-            # We've reached the end of the headers.
-            # Leave the rest in the output for later.
-            break
-        parser.feed(line)
-    msg = parser.close()
-    status = msg.get('Status', '200 OK I guess').encode('utf-8')
-    del msg['Status']  # this won't raise an exception
-    byte_headers = {
-        k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
-    }
-    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,
-    request: hgreq.parsedrequest,
-    response: hgreq.wsgiresponse,
-    check_permission: PermissionCheck,
-) -> bool:
-    """Intercepts requests from Git, if needed."""
-    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)
-
-    # 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)
-    http_backend = repo.ui.configlist(
-        b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
-    )
-    call = subprocess.Popen(
-        http_backend,
-        close_fds=True,
-        stdin=subprocess.PIPE,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.DEVNULL,
-        env=cgi_env,
-        text=False,
-    )
-    assert call.stdout
-    assert call.stdin
-    # Git will not start writing output until stdin is fully closed.
-    with call.stdin:
-        # This is how we know if there's anything to read from bodyfh.
-        # If we try to read from bodyfh on a request with no content,
-        # it hangs forever.
-        if b'CONTENT_LENGTH' in request.rawenv:
-            shutil.copyfileobj(request.bodyfh, call.stdin)
-
-    status, headers, rest = _parse_cgi_response(call.stdout)
-    response.status = status
-    for k, v in headers.items():
-        response.headers[k] = v
-
-    def write_the_rest() -> t.Iterator[bytes]:
-        with call, rest:
-            # if it's good enough for shutil it's good enough for me
-            # technically not in the docs but everybody it
-            bs = shutil.COPY_BUFSIZE  # type: ignore[attr-defined]
-            while more := rest.read(bs):
-                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) -> bool:
-    """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."""
-
-
-def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
-    """Creates a HEAD reference in Git referring to the current HEAD."""
-    # By default, we use '@', since that's what will be auto checked out.
-    current = b'@'
-    if current not in repo._bookmarks:
-        current = repo._bookmarks.active or current
-
-    # We'll be moving this (possibly fake) bookmark into Git.
-    git_current = current
-    if current == b'@':
-        # @ is a special keyword in Git, so we can't use it as a bookmark.
-        git_current = at_name
-    git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current
-    if not dulwich.refs.check_ref_format(git_branch):
-        # We can't export this ref to Git. Give up.
-        ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode())
-        return
-    try:
-        # Maybe this is a real bookmark?
-        hgnode = repo._bookmarks[current]
-    except KeyError:
-        # Not a real bookmark. Assume we want the tip of the current branch.
-        branch = repo.dirstate.branch()
-        try:
-            hgnode = repo.branchtip(branch)
-        except hgerr.RepoLookupError:
-            # This branch somehow doesn't exist???
-            ui.warn(f"{branch!r} doesn't seem to exist?".encode())
-            return
-    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!r} was not exported to Git'.encode())
-        return
-    refs = repo.githandler.git.refs
-    refs.add_packed_refs({git_branch: gitsha})
-    refs.set_symbolic_ref(b'HEAD', git_branch)
-
-
-def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
-    """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.
-
-    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(
-        b'hggit-serve', b'default-branch', b'default'
-    )
-    _set_head(ui, repo, default_branch_name)
-
-
-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 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.2.0'
-testedwith = b'7.1 7.2'
-minimumhgversion = b'7.1'
-
-cmdtable: dict[bytes, object] = {}
-
-command = registrar.command(cmdtable)
-
-
-def uisetup(_: hgui.ui) -> None:
-    extensions.wrapfunction(
-        wireprotoserver, 'handlewsgirequest', _handle_git_protocol
-    )
-
-
-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',
-)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/hgext3rd/hggit_serve/__init__.py	Wed Feb 18 16:17:05 2026 -0500
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import typing as t
+
+if t.TYPE_CHECKING:
+    import mercurial.ui as hgui
+
+from . import _export
+from . import _http
+
+#
+# Interfacing with Mercurial
+#
+
+__version__ = '0.2.1'
+testedwith = b'7.1 7.2'
+minimumhgversion = b'7.1'
+
+
+def uisetup(ui: hgui.ui) -> None:
+    _http.uisetup(ui)
+
+
+def uipopulate(ui: hgui.ui) -> None:
+    _export.uipopulate(ui)
+
+
+__all__ = (
+    '__version__',
+    'minimumhgversion',
+    'testedwith',
+    'uipopulate',
+    'uisetup',
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/hgext3rd/hggit_serve/_export.py	Wed Feb 18 16:17:05 2026 -0500
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import binascii
+import typing as t
+
+import dulwich.refs
+import mercurial.error as hgerr
+from hggit import git_handler
+
+if t.TYPE_CHECKING:
+    import mercurial.interfaces.repository as hgrepo
+    import mercurial.ui as hgui
+
+    class GittyRepo(hgrepo.IRepo, t.Protocol):
+        githandler: git_handler.GitHandler
+
+
+def is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]:
+    """Ensures that we have hg-git installed and active."""
+    return hasattr(repo, 'githandler')
+
+
+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*.
+"""
+
+
+#
+# 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) -> bool:
+    """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."""
+
+
+def set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
+    """Creates a HEAD reference in Git referring to the current HEAD."""
+    # By default, we use '@', since that's what will be auto checked out.
+    current = b'@'
+    if current not in repo._bookmarks:
+        current = repo._bookmarks.active or current
+
+    # We'll be moving this (possibly fake) bookmark into Git.
+    git_current = current
+    if current == b'@':
+        # @ is a special keyword in Git, so we can't use it as a bookmark.
+        git_current = at_name
+    git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current
+    if not dulwich.refs.check_ref_format(git_branch):
+        # We can't export this ref to Git. Give up.
+        ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode())
+        return
+    try:
+        # Maybe this is a real bookmark?
+        hgnode = repo._bookmarks[current]
+    except KeyError:
+        # Not a real bookmark. Assume we want the tip of the current branch.
+        branch = repo.dirstate.branch()
+        try:
+            hgnode = repo.branchtip(branch)
+        except hgerr.RepoLookupError:
+            # This branch somehow doesn't exist???
+            ui.warn(f"{branch!r} doesn't seem to exist?".encode())
+            return
+    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!r} was not exported to Git'.encode())
+        return
+    refs = repo.githandler.git.refs
+    refs.add_packed_refs({git_branch: gitsha})
+    refs.set_symbolic_ref(b'HEAD', git_branch)
+
+
+def fix_refs(ui: hgui.ui, repo: GittyRepo) -> None:
+    """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(
+        b'hggit-serve', b'default-branch', b'default'
+    )
+    set_head(ui, repo, default_branch_name)
+
+
+#
+# Hooks
+#
+
+
+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 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)
+
+
+def _fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
+    """Exports to Git and sets up for serving.  See ``_fix_refs``."""
+    if not is_gitty(repo):
+        return
+    fix_refs(ui, repo)
+
+
+#
+# Interfacing with Mercurial
+#
+
+
+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),
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/hgext3rd/hggit_serve/_http.py	Wed Feb 18 16:17:05 2026 -0500
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import email.parser
+import email.policy
+import re
+import shutil
+import subprocess
+import typing as t
+
+from mercurial import extensions
+from mercurial import wireprotoserver
+from mercurial.thirdparty import attr
+
+from . import _export as xp
+
+if t.TYPE_CHECKING:
+    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
+
+    PermissionCheck = t.Callable[
+        [web_inner.requestcontext, hgreq.parsedrequest, bytes],
+        None,
+    ]
+
+
+_CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
+"""Environment variables that we need to pass to git-as-cgi."""
+
+
+def _build_git_environ(
+    req_ctx: web_inner.requestcontext,
+    request: hgreq.parsedrequest,
+) -> dict[bytes, bytes]:
+    """Builds the environment to be sent to Git to serve HTTP."""
+    fixed = {
+        k: v
+        for (k, v) in request.rawenv.items()
+        if isinstance(v, bytes) and _CGI_VAR.match(k)
+    }
+    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
+
+
+def _parse_cgi_response(
+    output: t.IO[bytes],
+) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
+    """Parses a CGI response into a status, headers, and everyhting else."""
+    parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
+    while line := output.readline():
+        if not line.rstrip(b'\r\n'):
+            # We've reached the end of the headers.
+            # Leave the rest in the output for later.
+            break
+        parser.feed(line)
+    msg = parser.close()
+    status = msg.get('Status', '200 OK I guess').encode('utf-8')
+    del msg['Status']  # this won't raise an exception
+    byte_headers = {
+        k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
+    }
+    return status, byte_headers, output
+
+
+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 := xp.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 xp.SERVICE_PERMISSIONS.get(service)
+
+
+def _handle_git_protocol(
+    original: t.Callable[..., bool],
+    req_ctx: web_inner.requestcontext,
+    request: hgreq.parsedrequest,
+    response: hgreq.wsgiresponse,
+    check_permission: PermissionCheck,
+) -> bool:
+    """Intercepts requests from Git, if needed."""
+    perm = _git_service_permission(request)
+    repo: hgrepo.IRepo = req_ctx.repo
+    if not perm or not xp.is_gitty(repo):
+        # We only handle Git requests to Gitty repos.
+        return original(req_ctx, request, response, check_permission)
+
+    # 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 == xp.PUSH
+            else req_ctx.req
+        ),
+        perm,
+    )
+    cgi_env = _build_git_environ(req_ctx, request)
+    http_backend = repo.ui.configlist(
+        b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
+    )
+    call = subprocess.Popen(
+        http_backend,
+        close_fds=True,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.DEVNULL,
+        env=cgi_env,
+        text=False,
+    )
+    assert call.stdout
+    assert call.stdin
+    # Git will not start writing output until stdin is fully closed.
+    with call.stdin:
+        # This is how we know if there's anything to read from bodyfh.
+        # If we try to read from bodyfh on a request with no content,
+        # it hangs forever.
+        if b'CONTENT_LENGTH' in request.rawenv:
+            shutil.copyfileobj(request.bodyfh, call.stdin)
+
+    status, headers, rest = _parse_cgi_response(call.stdout)
+    response.status = status
+    for k, v in headers.items():
+        response.headers[k] = v
+
+    def write_the_rest() -> t.Iterator[bytes]:
+        with call, rest:
+            # if it's good enough for shutil it's good enough for me
+            # technically not in the docs but everybody it
+            bs = shutil.COPY_BUFSIZE  # type: ignore[attr-defined]
+            while more := rest.read(bs):
+                yield more
+        if perm == xp.PUSH:
+            # If we pushed, we need to import any new refs back into Mercurial.
+            xp.importing_enter(repo)
+            try:
+                gh = repo.githandler
+                gh.import_git_objects(
+                    b'git-push', remote_names=(), refs=gh.git.refs.as_dict()
+                )
+            finally:
+                xp.importing_exit(repo)
+
+    response.setbodygen(write_the_rest())
+    response.sendresponse()
+    return True
+
+
+#
+# Interfacing with Mercurial
+#
+
+
+def uisetup(_: hgui.ui) -> None:
+    extensions.wrapfunction(
+        wireprotoserver, 'handlewsgirequest', _handle_git_protocol
+    )