Mercurial > hg-git-serve
changeset 1:a39dd69b8972
Create a more-or-less real package and make it work (?)
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Sat, 14 Feb 2026 18:41:52 -0500 |
| parents | c1dc9d21fa57 |
| children | 871dcb2a2aeb |
| files | gitserve.py pyproject.toml src/git_serve.py |
| diffstat | 3 files changed, 305 insertions(+), 108 deletions(-) [+] |
line wrap: on
line diff
--- a/gitserve.py Sat Feb 14 05:48:46 2026 -0500 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,108 +0,0 @@ -import email.parser -import email.policy -import re -import threading -import shutil -import subprocess -import typing as t - -import mercurial.hgweb.hgweb_mod_inner as web_inner -import mercurial.hgweb.request as hgreq -import mercurial.ui as hgui -from mercurial import extensions -from mercurial import wireprotoserver - -type PermissionCheck = t.Callable[ - [web_inner.requestcontext, hgreq.parsedrequest, bytes], - None, -] - - -CGI_VAR = re.compile(rb'[A-Z0-9_]+$') - - -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[b'GIT_HTTP_EXPORT_ALL'] = b'yes' - fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path - fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath - return fixed - - -def parse_cgi_response(output: t.IO[bytes]) -> t.Tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: - 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 - - -_feeds = 0 -_feeder_lock = threading.Lock() - - -def feed_count() -> int: - global _feeds - with _feeder_lock: - _feeds += 1 - return _feeds - - -def handle_git_protocol( - original: t.Callable[..., bool], - req_ctx: web_inner.requestcontext, - request: hgreq.parsedrequest, - response: hgreq.wsgiresponse, - check_permission: PermissionCheck, -) -> bool: - if request.headers.get(b'git-protocol'): - # If a request is git, we assume we should be the one handling it. - cgi_env = build_git_environ(req_ctx, request) - http_backend = req_ctx.repo.ui.configlist( - b'git-serve', b'http-backend', default=(b'git', b'http-backend')) - is_post = request.method == b'POST' - call = subprocess.Popen( - http_backend, - close_fds=True, - stdin=subprocess.PIPE if is_post else None, - stdout=subprocess.PIPE, - env=cgi_env, - text=False, - ) - def feed(): - try: - with call.stdin as stdin: - shutil.copyfileobj(request.bodyfh, stdin) - except (OSError, BrokenPipeError): - pass # Expected; this just means it's closed. - if is_post: - threading.Thread(target=feed, name=f'git-feeder-{feed_count()}').start() - 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(): - with call, rest: - while more := rest.read(64 * 1024): - yield more - response.setbodygen(write_the_rest()) - response.sendresponse() - return True - return original(req_ctx, request, response, check_permission) - -def uisetup(_: hgui.ui) -> None: - extensions.wrapfunction(wireprotoserver, 'handlewsgirequest', handle_git_protocol) \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pyproject.toml Sat Feb 14 18:41:52 2026 -0500 @@ -0,0 +1,70 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "git-serve" +dynamic = ["version"] +description = 'Serves Git repositories from Mercurial.' +requires-python = ">=3.8" +license = "MIT" +keywords = [] +authors = [ + { name = "Paul Fisher", email = "paul@pfish.zone" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + 'dulwich', + 'mercurial', + 'hg-git', +] + +[tool.hatch.version] +path = "src/git_serve.py" + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/git_serve tests}" + +[tool.coverage.run] +source_pkgs = ["git_serve", "tests"] +branch = true +parallel = true + +[tool.coverage.paths] +git_serve = ["src/git_serve", "*/git-serve/src/git_serve"] +tests = ["tests", "*/git-serve/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[[tool.mypy.overrides]] +module = ['mercurial.*', 'hggit.*'] +follow_untyped_imports = true + +[tool.ruff] +line-length = 80 + +[tool.ruff.lint] +select = ['E', 'F', 'UP', 'B', 'SIM', 'I'] +isort.force-single-line = true + +[tool.ruff.format] +quote-style = 'single'
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/git_serve.py Sat Feb 14 18:41:52 2026 -0500 @@ -0,0 +1,235 @@ +from __future__ import annotations + +import binascii +import email.parser +import email.policy +import os +import pathlib +import re +import shutil +import subprocess +import threading +import typing as t + +import dulwich.refs +import mercurial.error as hgerr +from mercurial import extensions +from mercurial import registrar +from mercurial import wireprotoserver + +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 + + PermissionCheck = t.Callable[ + [web_inner.requestcontext, hgreq.parsedrequest, bytes], + None, + ] + GitPrelude = t.Sequence[bytes | str | os.PathLike] + + +_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[b'GIT_HTTP_EXPORT_ALL'] = b'yes' + fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path + fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath + return fixed + + +def _parse_cgi_response( + output: t.IO[bytes], +) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: + 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 + + +_feeds = 0 +_feeder_lock = threading.Lock() + + +def feed_count() -> int: + global _feeds + with _feeder_lock: + _feeds += 1 + return _feeds + + +def git_binary(ui: hgui.ui) -> bytes: + return ui.config(b'git-serve', b'git', default=b'git') + + +def handle_git_protocol( + original: t.Callable[..., bool], + req_ctx: web_inner.requestcontext, + request: hgreq.parsedrequest, + response: hgreq.wsgiresponse, + check_permission: PermissionCheck, +) -> bool: + 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. + 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. + cgi_env = _build_git_environ(req_ctx, request) + http_backend = req_ctx.repo.ui.configlist( + b'git-serve', b'http-backend', default=(b'git', b'http-backend') + ) + is_post = request.method == b'POST' + call = subprocess.Popen( + http_backend, + close_fds=True, + stdin=subprocess.PIPE if is_post else None, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=cgi_env, + text=False, + ) + assert call.stdout + + def feed(): + try: + with call.stdin as stdin: + shutil.copyfileobj(request.bodyfh, stdin) + except (OSError, BrokenPipeError): + pass # Expected; this just means it's closed. + + if is_post: + threading.Thread(target=feed, name=f'git-feeder-{feed_count()}').start() + 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(): + with call, rest: + while more := rest.read(64 * 1024): + yield more + + response.setbodygen(write_the_rest()) + response.sendresponse() + return True + + +def clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: + for ref in refs.allkeys(): + refs.remove_if_equals(ref, None) + + +def set_head(repo: GittyRepo) -> 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 = b'__default__' + 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. + return + refs = repo.githandler.git.refs + if git_branch not in refs: + # This means our bookmark isn't actually in Git (usually because + # there's no real bookmark called '@'). We need to fake it. + try: + # Maybe this is a real bookmark? + hgsha = 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) + except hgerr.RepoLookupError: + # This branch somehow doesn't exist??? + return + hgsha = binascii.hexlify(tip) + gitsha = repo.githandler.map_git_get(hgsha) + if not gitsha: + # No Git SHA to match this Hg sha. Give up. + return + refs.add_packed_refs({git_branch: gitsha}) + refs.set_symbolic_ref(b'HEAD', git_branch) + + +def export_hook(ui: hgui.ui, repo: GittyRepo, **__: object) -> None: + never_export = ui.configbool(b'git-serve', b'never-export') + if never_export: + return + git_repo = pathlib.Path(repo.githandler.gitdir) + always_export = ui.configbool(b'git-serve', b'always-export', False) + if always_export or git_repo.exists(): + export_repo(repo) + + +def export_repo(repo: GittyRepo) -> None: + clean_all_refs(repo.githandler.git.refs) + repo.githandler.export_commits() + set_head(repo) + + +def is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: + return hasattr(repo, 'githandler') + + +# Interfacing with Mercurial + +__version__ = '0.1' + +cmdtable: dict[bytes, object] = {} + +command = registrar.command(cmdtable) + + +@command(b'git-serve-export') +def git_serve_export(_: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: + if not is_gitty(repo): + raise hgerr.Abort(b'this extension depends on the `hggit` extension') + export_repo(repo) + + +def uisetup(_: hgui.ui) -> None: + extensions.wrapfunction( + wireprotoserver, 'handlewsgirequest', handle_git_protocol + ) + + +def reposetup(ui: hgui.ui, _: hgrepo.IRepo) -> None: + ui.setconfig(b'hooks', b'txnclose.__gitserve_internal__', export_hook) + + +__all__ = ('__version__', 'cmdtable', 'command', 'uisetup', 'reposetup')
