Mercurial > hg-git-serve
comparison src/hggit_serve.py @ 7:4f42fdbb25f2
rename to hggit_serve
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Sun, 15 Feb 2026 01:49:42 -0500 |
| parents | src/git_serve/__init__.py@7113e0ac3662 |
| children | fe3c9fae4d4d |
comparison
equal
deleted
inserted
replaced
| 6:7113e0ac3662 | 7:4f42fdbb25f2 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import binascii | |
| 4 import email.parser | |
| 5 import email.policy | |
| 6 import os.path | |
| 7 import re | |
| 8 import shutil | |
| 9 import subprocess | |
| 10 import typing as t | |
| 11 | |
| 12 import dulwich.refs | |
| 13 import mercurial.error as hgerr | |
| 14 from mercurial import extensions | |
| 15 from mercurial import registrar | |
| 16 from mercurial import wireprotoserver | |
| 17 | |
| 18 if t.TYPE_CHECKING: | |
| 19 import hggit.git_handler | |
| 20 import mercurial.hgweb.hgweb_mod_inner as web_inner | |
| 21 import mercurial.hgweb.request as hgreq | |
| 22 import mercurial.interfaces.repository as hgrepo | |
| 23 import mercurial.ui as hgui | |
| 24 | |
| 25 class GittyRepo(hgrepo.IRepo, t.Protocol): | |
| 26 githandler: hggit.git_handler.GitHandler | |
| 27 | |
| 28 PermissionCheck = t.Callable[ | |
| 29 [web_inner.requestcontext, hgreq.parsedrequest, bytes], | |
| 30 None, | |
| 31 ] | |
| 32 GitPrelude = t.Sequence[bytes | str | os.PathLike] | |
| 33 | |
| 34 | |
| 35 def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: | |
| 36 """Ensures that we have hg-git installed and active.""" | |
| 37 return hasattr(repo, 'githandler') | |
| 38 | |
| 39 | |
| 40 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$') | |
| 41 """Environment variables that we need to pass to git-as-cgi.""" | |
| 42 | |
| 43 | |
| 44 def _build_git_environ( | |
| 45 req_ctx: web_inner.requestcontext, | |
| 46 request: hgreq.parsedrequest, | |
| 47 ) -> dict[bytes, bytes]: | |
| 48 """Builds the environment to be sent to Git to serve HTTP.""" | |
| 49 fixed = { | |
| 50 k: v | |
| 51 for (k, v) in request.rawenv.items() | |
| 52 if isinstance(v, bytes) and _CGI_VAR.match(k) | |
| 53 } | |
| 54 fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes' | |
| 55 fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path | |
| 56 fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath | |
| 57 return fixed | |
| 58 | |
| 59 | |
| 60 def _parse_cgi_response( | |
| 61 output: t.IO[bytes], | |
| 62 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: | |
| 63 """Parses a CGI response into a status, headers, and everyhting else.""" | |
| 64 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) | |
| 65 while line := output.readline(): | |
| 66 if not line.rstrip(b'\r\n'): | |
| 67 # We've reached the end of the headers. | |
| 68 # Leave the rest in the output for later. | |
| 69 break | |
| 70 parser.feed(line) | |
| 71 msg = parser.close() | |
| 72 status = msg.get('Status', '200 OK I guess').encode('utf-8') | |
| 73 del msg['Status'] # this won't raise an exception | |
| 74 byte_headers = { | |
| 75 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() | |
| 76 } | |
| 77 return status, byte_headers, output | |
| 78 | |
| 79 | |
| 80 def _handle_git_protocol( | |
| 81 original: t.Callable[..., bool], | |
| 82 req_ctx: web_inner.requestcontext, | |
| 83 request: hgreq.parsedrequest, | |
| 84 response: hgreq.wsgiresponse, | |
| 85 check_permission: PermissionCheck, | |
| 86 ) -> bool: | |
| 87 """Intercepts requests from Git, if needed.""" | |
| 88 repo = req_ctx.repo | |
| 89 if not _is_gitty(repo) or b'git-protocol' not in request.headers: | |
| 90 # We only handle Git requests; everything else is normal. | |
| 91 return original(req_ctx, request, response, check_permission) | |
| 92 check_permission(req_ctx, request, b'pull') | |
| 93 # If a request is git, we assume we should be the one handling it. | |
| 94 cgi_env = _build_git_environ(req_ctx, request) | |
| 95 content_length_hdr = request.headers.get(b'content-length', b'0') | |
| 96 try: | |
| 97 content_length = int(content_length_hdr) | |
| 98 except ValueError as ve: | |
| 99 raise hgerr.InputError( | |
| 100 f'Invalid content-length {content_length!r}'.encode() | |
| 101 ) from ve | |
| 102 http_backend = req_ctx.repo.ui.configlist( | |
| 103 b'git-serve', b'http-backend', default=(b'git', b'http-backend') | |
| 104 ) | |
| 105 call = subprocess.Popen( | |
| 106 http_backend, | |
| 107 close_fds=True, | |
| 108 stdin=subprocess.PIPE, | |
| 109 stdout=subprocess.PIPE, | |
| 110 stderr=subprocess.DEVNULL, | |
| 111 env=cgi_env, | |
| 112 text=False, | |
| 113 ) | |
| 114 assert call.stdout | |
| 115 assert call.stdin | |
| 116 # Git will not start writing output until stdin is fully closed. | |
| 117 with call.stdin: | |
| 118 if content_length: | |
| 119 shutil.copyfileobj( | |
| 120 request.bodyfh, call.stdin, length=content_length | |
| 121 ) | |
| 122 | |
| 123 status, headers, rest = _parse_cgi_response(call.stdout) | |
| 124 response.status = status | |
| 125 for k, v in headers.items(): | |
| 126 response.headers[k] = v | |
| 127 | |
| 128 def write_the_rest(): | |
| 129 with call, rest: | |
| 130 while more := rest.read(1024 * 1024): | |
| 131 yield more | |
| 132 | |
| 133 response.setbodygen(write_the_rest()) | |
| 134 response.sendresponse() | |
| 135 return True | |
| 136 | |
| 137 | |
| 138 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: | |
| 139 """Removes all refs from the Git repository.""" | |
| 140 for ref in refs.allkeys(): | |
| 141 refs.remove_if_equals(ref, None) | |
| 142 | |
| 143 | |
| 144 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None: | |
| 145 """Creates a HEAD reference in Git referring to the current HEAD.""" | |
| 146 # By default, we use '@', since that's what will be auto checked out. | |
| 147 current = b'@' | |
| 148 if current not in repo._bookmarks: | |
| 149 current = repo._bookmarks.active or current | |
| 150 | |
| 151 # We'll be moving this (possibly fake) bookmark into Git. | |
| 152 git_current = current | |
| 153 if current == b'@': | |
| 154 # @ is a special keyword in Git, so we can't use it as a bookmark. | |
| 155 git_current = at_name | |
| 156 git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current | |
| 157 if not dulwich.refs.check_ref_format(git_branch): | |
| 158 # We can't export this ref to Git. Give up. | |
| 159 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode()) | |
| 160 return | |
| 161 try: | |
| 162 # Maybe this is a real bookmark? | |
| 163 hgsha = repo._bookmarks[current] | |
| 164 except KeyError: | |
| 165 # Not a real bookmark. Assume we want the tip of the current branch. | |
| 166 branch = repo.dirstate.branch() | |
| 167 try: | |
| 168 tip = repo.branchtip(branch) | |
| 169 except hgerr.RepoLookupError: | |
| 170 # This branch somehow doesn't exist??? | |
| 171 ui.warn(f"{branch} doesn't seem to exist?".encode()) | |
| 172 return | |
| 173 hgsha = binascii.hexlify(tip) | |
| 174 gitsha = repo.githandler.map_git_get(hgsha) | |
| 175 if not gitsha: | |
| 176 # No Git SHA to match this Hg sha. Give up. | |
| 177 ui.warn(f'revision {hgsha} was not exported to Git'.encode()) | |
| 178 return | |
| 179 refs = repo.githandler.git.refs | |
| 180 refs.add_packed_refs({git_branch: gitsha}) | |
| 181 refs.set_symbolic_ref(b'HEAD', git_branch) | |
| 182 | |
| 183 | |
| 184 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: | |
| 185 """Exports to Git and sets up for serving.""" | |
| 186 if not _is_gitty(repo): | |
| 187 return | |
| 188 _fix_refs(ui, repo) | |
| 189 | |
| 190 | |
| 191 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None: | |
| 192 """After a git export, fix up the refs.""" | |
| 193 _clean_all_refs(repo.githandler.git.refs) | |
| 194 repo.githandler.export_hg_tags() | |
| 195 repo.githandler.update_references() | |
| 196 default_branch_name = ui.config( | |
| 197 b'hggit-serve', b'default-branch', b'default' | |
| 198 ) | |
| 199 _set_head(ui, repo, default_branch_name) | |
| 200 | |
| 201 | |
| 202 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: | |
| 203 if not _is_gitty(repo): | |
| 204 return | |
| 205 auto_export = ui.config(b'hggit-serve', b'auto-export') | |
| 206 if auto_export == b'never': | |
| 207 return | |
| 208 if auto_export == b'always' or os.path.isdir(repo.githandler.gitdir): | |
| 209 repo.githandler.export_commits() | |
| 210 _fix_refs(ui, repo) | |
| 211 | |
| 212 | |
| 213 # Interfacing with Mercurial | |
| 214 | |
| 215 __version__ = '0.1.5' | |
| 216 testedwith = b'7.1 7.2' | |
| 217 | |
| 218 cmdtable: dict[bytes, object] = {} | |
| 219 | |
| 220 command = registrar.command(cmdtable) | |
| 221 | |
| 222 | |
| 223 def uisetup(_: hgui.ui) -> None: | |
| 224 extensions.wrapfunction( | |
| 225 wireprotoserver, 'handlewsgirequest', _handle_git_protocol | |
| 226 ) | |
| 227 | |
| 228 | |
| 229 def uipopulate(ui: hgui.ui) -> None: | |
| 230 ui.setconfig( | |
| 231 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook | |
| 232 ) | |
| 233 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook) | |
| 234 | |
| 235 | |
| 236 __all__ = ( | |
| 237 '__version__', | |
| 238 'cmdtable', | |
| 239 'command', | |
| 240 'testedwith', | |
| 241 'uipopulate', | |
| 242 'uisetup', | |
| 243 ) |
