changeset 13:00bdfac5416c

Create Git SSH commands and add some documentation. Also cleanup. - Adds git-upload-pack and git-receive-pack as hg subcommands, to be run on the server side by git push/pull. - Starts on documentation. - Cleans up a lot of stuff.
author Paul Fisher <paul@pfish.zone>
date Thu, 19 Feb 2026 01:13:56 -0500
parents f630d9904ea7
children 959ef686193f
files src/hgext3rd/hggit_serve/__init__.py src/hgext3rd/hggit_serve/_export.py src/hgext3rd/hggit_serve/_http.py src/hgext3rd/hggit_serve/_ssh.py
diffstat 4 files changed, 187 insertions(+), 47 deletions(-) [+]
line wrap: on
line diff
--- a/src/hgext3rd/hggit_serve/__init__.py	Wed Feb 18 16:17:05 2026 -0500
+++ b/src/hgext3rd/hggit_serve/__init__.py	Thu Feb 19 01:13:56 2026 -0500
@@ -1,32 +1,45 @@
+r"""hg serve the Git world
+
+This extension lets you serve Git users from a Mercurial world. After some
+very basic setup, Git users can pull from *and push to* your repository
+as if it were any other Git repository.
+
+For a quick example::
+
+  $ hg git-export
+  $ hg serve
+
+then in another terminal::
+
+  $ git clone http://localhost:8000/  # or wherever 'hg serve' ran
+
+This works atop the ``hggit`` extension, and (unlike ``hggit``) requires that
+you have Git installed (maybe this will go away in a future version?).
+
+Setup
+-----
+
+TODO
+"""
+
 from __future__ import annotations
 
-import typing as t
-
-if t.TYPE_CHECKING:
-    import mercurial.ui as hgui
-
-from . import _export
-from . import _http
+from ._export import uipopulate
+from ._http import uisetup
+from ._ssh import cmdtable
 
 #
 # Interfacing with Mercurial
 #
 
-__version__ = '0.2.1'
+__version__ = '0.3.0'
 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__',
+    'cmdtable',
     'minimumhgversion',
     'testedwith',
     'uipopulate',
--- a/src/hgext3rd/hggit_serve/_export.py	Wed Feb 18 16:17:05 2026 -0500
+++ b/src/hgext3rd/hggit_serve/_export.py	Thu Feb 19 01:13:56 2026 -0500
@@ -48,18 +48,18 @@
 """
 
 
-def importing_enter(repo: hgrepo.IRepo) -> None:
+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:
+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:
+def _importing_exit(repo: hgrepo.IRepo) -> None:
     """Call this after you finish importing from Git."""
     level = getattr(repo, _ILEVEL_ATTR) - 1
     if level:
@@ -68,16 +68,31 @@
         delattr(repo, _ILEVEL_ATTR)
 
 
+def import_all(repo: GittyRepo, command: bytes = b'(unknown)') -> None:
+    _importing_enter(repo)
+    try:
+        gh = repo.githandler
+        gh.import_git_objects(
+            command, remote_names=(), refs=gh.git.refs.as_dict()
+        )
+    finally:
+        _importing_exit(repo)
+
+
 #
 # Export handling.
 #
 
 
-def clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
+def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
     """Removes all refs from the Git repository."""
+    # 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)
 
 
-def set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
+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'@'
@@ -117,23 +132,19 @@
     refs.set_symbolic_ref(b'HEAD', git_branch)
 
 
-def fix_refs(ui: hgui.ui, repo: GittyRepo) -> None:
+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)
+    _clean_all_refs(repo.githandler.git.refs)
     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)
+    _set_head(ui, repo, default_branch_name)
 
 
 #
@@ -149,18 +160,18 @@
     if auto_export == b'never':
         return
     if auto_export == b'always' or git_handler.has_gitrepo(repo):
-        if is_importing(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)
+        _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)
+    _fix_refs(ui, repo)
 
 
 #
@@ -171,18 +182,28 @@
 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
+        b'hooks',
+        b'post-git-export.__gitserve_add_tag__',
+        _fix_refs_hook,
+        source=b'hggit_serve',
     )
     # Whenever we get new revisions, export them to the Git repository.
-    ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', _export_hook)
+    ui.setconfig(
+        b'hooks',
+        b'txnclose.__gitserve_export__',
+        _export_hook,
+        source=b'hggit_serve',
+    )
     # 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),
+        lambda _, repo, **__: _importing_enter(repo),
+        source=b'hggit_serve',
     )
     ui.setconfig(
         b'hooks',
         b'post-git-import.__gitserve_suppress_export__',
-        lambda _, repo, **__: importing_exit(repo),
+        lambda _, repo, **__: _importing_exit(repo),
+        source=b'hggit-serve',
     )
--- a/src/hgext3rd/hggit_serve/_http.py	Wed Feb 18 16:17:05 2026 -0500
+++ b/src/hgext3rd/hggit_serve/_http.py	Thu Feb 19 01:13:56 2026 -0500
@@ -16,7 +16,6 @@
 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[
@@ -39,11 +38,13 @@
         for (k, v) in request.rawenv.items()
         if isinstance(v, bytes) and _CGI_VAR.match(k)
     }
+    repo = req_ctx.repo
+    assert xp.is_gitty(repo)
     fixed.update(
         {
             b'GIT_HTTP_EXPORT_ALL': b'yes',
-            b'GIT_PROJECT_ROOT': req_ctx.repo.path,
-            b'PATH_INFO': b'/git/' + request.dispatchpath,
+            b'GIT_PROJECT_ROOT': repo.githandler.gitdir,
+            b'PATH_INFO': b'/' + request.dispatchpath,
             # Since Mercurial is taking care of authorization checking,
             # we tell Git to always allow push.
             b'GIT_CONFIG_COUNT': b'1',
@@ -101,7 +102,7 @@
 ) -> bool:
     """Intercepts requests from Git, if needed."""
     perm = _git_service_permission(request)
-    repo: hgrepo.IRepo = req_ctx.repo
+    repo = 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)
@@ -155,14 +156,7 @@
                 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)
+            xp.import_all(repo, b'git-http-push')
 
     response.setbodygen(write_the_rest())
     response.sendresponse()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/hgext3rd/hggit_serve/_ssh.py	Thu Feb 19 01:13:56 2026 -0500
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import subprocess
+import typing as t
+
+from mercurial import error as hgerr
+from mercurial import registrar
+
+from . import _export as xp
+
+if t.TYPE_CHECKING:
+    import mercurial.interfaces.repository as hgrepo
+    import mercurial.ui as hgui
+
+cmdtable: dict[bytes, object] = {}
+_command = registrar.command(cmdtable)
+
+
+def _not_git() -> hgerr.StateError:
+    return hgerr.StateError(
+        b'Git is not enabled for this repository.',
+        hint=b'The server administrator should enable the ``hggit`` extension '
+             b'and run ``hg git-export`` to enable Git access.',
+    )
+
+
+def _maybe(flag: bytes, include: bool) -> tuple[bytes, ...]:
+    return (flag,) if include else ()
+
+
+@_command(
+    b'git-upload-pack',
+    [
+        *(
+            (b'', opt, False, b'flag passed to git upload-pack')
+            for opt in (
+                b'strict',
+                b'no-strict',
+                b'stateless-rpc',
+                b'advertise-refs',
+            )
+        ),
+        (b'', b'timeout', -1, b'flag passed to git upload-pack'),
+    ],
+    helpcategory=b'import',
+    intents=(b'readonly',),
+)
+def _git_upload_pack(
+    ui: hgui.ui,
+    repo: hgrepo.IRepo,
+    *,
+    strict: bool,
+    no_strict: bool,
+    stateless_rpc: bool,
+    advertise_refs: bool,
+    timeout: int,
+) -> int:
+    """Server-side handler for ``git pull``/``git clone``."""
+    if not xp.is_gitty(repo):
+        raise _not_git()
+    timeout_flag = (
+        (b'--timeout=' + str(timeout).encode(),) if timeout != -1 else ()
+    )
+    upload_pack = ui.configlist(
+        b'hggit-serve', b'upload-pack', default=(b'git', b'upload-pack')
+    )
+    return subprocess.call(
+        (
+            *upload_pack,
+            *_maybe(b'--strict', strict),
+            *_maybe(b'--no-strict', no_strict),
+            *_maybe(b'--stateless-rpc', stateless_rpc),
+            *_maybe(b'--advertise_refs', advertise_refs),
+            *timeout_flag,
+            repo.githandler.gitdir,
+        ),
+        close_fds=True,
+    )
+
+
+@_command(
+    b'git-receive-pack',
+    [
+        (b'', b'skip-connectivity-check', False, b'passed to git receive-pack'),
+    ],
+    helpcategory=b'import',
+)
+def _git_receive_pack(
+    ui: hgui.ui, repo: hgrepo.IRepo, *, skip_connectivity_check: bool
+) -> int:
+    """Server-side handler for ``git push``."""
+    if not xp.is_gitty(repo):
+        raise _not_git()
+    receive_pack = ui.configlist(
+        b'hggit-serve', b'receive-pack', default=(b'git', b'receive-pack')
+    )
+    try:
+        subprocess.check_call(
+            (
+                *receive_pack,
+                *_maybe(b'--skip-connectivity-check', skip_connectivity_check),
+                repo.githandler.gitdir,
+            ),
+            close_fds=True,
+        )
+    except subprocess.CalledProcessError as cpe:
+        return cpe.returncode
+    xp.import_all(repo, b'git-receive-pack')
+    return 0
+
+
+__all__ = ('cmdtable',)