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