Mercurial > hg-git-serve
comparison src/hgext3rd/hggit_serve.py @ 11:ce204bcc4e04
Move to hgext3rd/hggit_serve.py.
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Wed, 18 Feb 2026 14:45:19 -0500 |
| parents | src/hggit_serve.py@c2ae14c981e1 |
| children |
comparison
equal
deleted
inserted
replaced
| 10:c2ae14c981e1 | 11:ce204bcc4e04 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import binascii | |
| 4 import email.parser | |
| 5 import email.policy | |
| 6 import re | |
| 7 import shutil | |
| 8 import subprocess | |
| 9 import typing as t | |
| 10 | |
| 11 import dulwich.refs | |
| 12 import mercurial.error as hgerr | |
| 13 from hggit import git_handler | |
| 14 from mercurial import extensions | |
| 15 from mercurial import registrar | |
| 16 from mercurial import wireprotoserver | |
| 17 from mercurial.thirdparty import attr | |
| 18 | |
| 19 if t.TYPE_CHECKING: | |
| 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: git_handler.GitHandler | |
| 27 | |
| 28 PermissionCheck = t.Callable[ | |
| 29 [web_inner.requestcontext, hgreq.parsedrequest, bytes], | |
| 30 None, | |
| 31 ] | |
| 32 | |
| 33 | |
| 34 def _is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: | |
| 35 """Ensures that we have hg-git installed and active.""" | |
| 36 return hasattr(repo, 'githandler') | |
| 37 | |
| 38 | |
| 39 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$') | |
| 40 """Environment variables that we need to pass to git-as-cgi.""" | |
| 41 | |
| 42 | |
| 43 def _build_git_environ( | |
| 44 req_ctx: web_inner.requestcontext, | |
| 45 request: hgreq.parsedrequest, | |
| 46 ) -> dict[bytes, bytes]: | |
| 47 """Builds the environment to be sent to Git to serve HTTP.""" | |
| 48 fixed = { | |
| 49 k: v | |
| 50 for (k, v) in request.rawenv.items() | |
| 51 if isinstance(v, bytes) and _CGI_VAR.match(k) | |
| 52 } | |
| 53 fixed.update( | |
| 54 { | |
| 55 b'GIT_HTTP_EXPORT_ALL': b'yes', | |
| 56 b'GIT_PROJECT_ROOT': req_ctx.repo.path, | |
| 57 b'PATH_INFO': b'/git/' + request.dispatchpath, | |
| 58 # Since Mercurial is taking care of authorization checking, | |
| 59 # we tell Git to always allow push. | |
| 60 b'GIT_CONFIG_COUNT': b'1', | |
| 61 b'GIT_CONFIG_KEY_0': b'http.receivepack', | |
| 62 b'GIT_CONFIG_VALUE_0': b'true', | |
| 63 } | |
| 64 ) | |
| 65 return fixed | |
| 66 | |
| 67 | |
| 68 def _parse_cgi_response( | |
| 69 output: t.IO[bytes], | |
| 70 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: | |
| 71 """Parses a CGI response into a status, headers, and everyhting else.""" | |
| 72 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) | |
| 73 while line := output.readline(): | |
| 74 if not line.rstrip(b'\r\n'): | |
| 75 # We've reached the end of the headers. | |
| 76 # Leave the rest in the output for later. | |
| 77 break | |
| 78 parser.feed(line) | |
| 79 msg = parser.close() | |
| 80 status = msg.get('Status', '200 OK I guess').encode('utf-8') | |
| 81 del msg['Status'] # this won't raise an exception | |
| 82 byte_headers = { | |
| 83 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() | |
| 84 } | |
| 85 return status, byte_headers, output | |
| 86 | |
| 87 | |
| 88 _PULL = b'pull' | |
| 89 _PUSH = b'push' | |
| 90 | |
| 91 _SERVICE_PERMISSIONS = { | |
| 92 b'git-upload-pack': _PULL, | |
| 93 b'git-receive-pack': _PUSH, | |
| 94 } | |
| 95 """The Mercurial permission corresponding to each Git action. | |
| 96 | |
| 97 These seem backwards because the direction of up/download is relative to | |
| 98 the server, so when the client pulls, the server is *uploading*, | |
| 99 and when the client pushes, the server is *downloading*. | |
| 100 """ | |
| 101 | |
| 102 | |
| 103 def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None: | |
| 104 """Figures out what Mercurial permission corresponds to a request from Git. | |
| 105 | |
| 106 If the request is a supported Git action, returns the permission it needs. | |
| 107 If the request is not a Git action, returns None. | |
| 108 """ | |
| 109 if perm := _SERVICE_PERMISSIONS.get(request.dispatchpath): | |
| 110 return perm | |
| 111 if request.dispatchpath != b'info/refs': | |
| 112 return None | |
| 113 qs = request.querystring | |
| 114 service = qs.removeprefix(b'service=') | |
| 115 if qs == service: | |
| 116 # Nothing was stripped. | |
| 117 return None | |
| 118 return _SERVICE_PERMISSIONS.get(service) | |
| 119 | |
| 120 | |
| 121 def _handle_git_protocol( | |
| 122 original: t.Callable[..., bool], | |
| 123 req_ctx: web_inner.requestcontext, | |
| 124 request: hgreq.parsedrequest, | |
| 125 response: hgreq.wsgiresponse, | |
| 126 check_permission: PermissionCheck, | |
| 127 ) -> bool: | |
| 128 """Intercepts requests from Git, if needed.""" | |
| 129 perm = _git_service_permission(request) | |
| 130 repo: hgrepo.IRepo = req_ctx.repo | |
| 131 if not perm or not _is_gitty(repo): | |
| 132 # We only handle Git requests to Gitty repos. | |
| 133 return original(req_ctx, request, response, check_permission) | |
| 134 | |
| 135 # Permission workaround: Mercurial requires POSTs for push, | |
| 136 # but the advertisement request from Git will be a GET. | |
| 137 # We just lie to Mercurial about what we're doing. | |
| 138 check_permission( | |
| 139 req_ctx, | |
| 140 ( | |
| 141 attr.evolve(req_ctx.req, method=b'POST') | |
| 142 if perm == _PUSH | |
| 143 else req_ctx.req | |
| 144 ), | |
| 145 perm, | |
| 146 ) | |
| 147 cgi_env = _build_git_environ(req_ctx, request) | |
| 148 http_backend = repo.ui.configlist( | |
| 149 b'hggit-serve', b'http-backend', default=(b'git', b'http-backend') | |
| 150 ) | |
| 151 call = subprocess.Popen( | |
| 152 http_backend, | |
| 153 close_fds=True, | |
| 154 stdin=subprocess.PIPE, | |
| 155 stdout=subprocess.PIPE, | |
| 156 stderr=subprocess.DEVNULL, | |
| 157 env=cgi_env, | |
| 158 text=False, | |
| 159 ) | |
| 160 assert call.stdout | |
| 161 assert call.stdin | |
| 162 # Git will not start writing output until stdin is fully closed. | |
| 163 with call.stdin: | |
| 164 # This is how we know if there's anything to read from bodyfh. | |
| 165 # If we try to read from bodyfh on a request with no content, | |
| 166 # it hangs forever. | |
| 167 if b'CONTENT_LENGTH' in request.rawenv: | |
| 168 shutil.copyfileobj(request.bodyfh, call.stdin) | |
| 169 | |
| 170 status, headers, rest = _parse_cgi_response(call.stdout) | |
| 171 response.status = status | |
| 172 for k, v in headers.items(): | |
| 173 response.headers[k] = v | |
| 174 | |
| 175 def write_the_rest() -> t.Iterator[bytes]: | |
| 176 with call, rest: | |
| 177 # if it's good enough for shutil it's good enough for me | |
| 178 # technically not in the docs but everybody it | |
| 179 bs = shutil.COPY_BUFSIZE # type: ignore[attr-defined] | |
| 180 while more := rest.read(bs): | |
| 181 yield more | |
| 182 if perm == _PUSH: | |
| 183 _importing_enter(repo) | |
| 184 try: | |
| 185 gh = repo.githandler | |
| 186 gh.import_git_objects( | |
| 187 b'git-push', remote_names=(), refs=gh.git.refs.as_dict() | |
| 188 ) | |
| 189 finally: | |
| 190 _importing_exit(repo) | |
| 191 | |
| 192 response.setbodygen(write_the_rest()) | |
| 193 response.sendresponse() | |
| 194 return True | |
| 195 | |
| 196 | |
| 197 # | |
| 198 # Stuff so that we don't try to export revisions while we're importing. | |
| 199 # | |
| 200 | |
| 201 _ILEVEL_ATTR = '@hggit_import_level' | |
| 202 """An attribute that tracks how many "levels deep" we are into importing. | |
| 203 | |
| 204 We set this on the repository object when we're importing and remove it | |
| 205 when we're done. It's not just a bool in case somebody sets up some crazy | |
| 206 recursive hook situation where we start importing inside another import. | |
| 207 """ | |
| 208 | |
| 209 | |
| 210 def _importing_enter(repo: hgrepo.IRepo) -> None: | |
| 211 """Call this before you start importing from Git.""" | |
| 212 level = getattr(repo, _ILEVEL_ATTR, 0) + 1 | |
| 213 setattr(repo, _ILEVEL_ATTR, level) | |
| 214 | |
| 215 | |
| 216 def _is_importing(repo: hgrepo.IRepo) -> bool: | |
| 217 """Call this to check if you're currently importing.""" | |
| 218 return hasattr(repo, _ILEVEL_ATTR) | |
| 219 | |
| 220 | |
| 221 def _importing_exit(repo: hgrepo.IRepo) -> None: | |
| 222 """Call this after you finish importing from Git.""" | |
| 223 level = getattr(repo, _ILEVEL_ATTR) - 1 | |
| 224 if level: | |
| 225 setattr(repo, _ILEVEL_ATTR, level) | |
| 226 else: | |
| 227 delattr(repo, _ILEVEL_ATTR) | |
| 228 | |
| 229 | |
| 230 # | |
| 231 # Export handling. | |
| 232 # | |
| 233 | |
| 234 | |
| 235 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: | |
| 236 """Removes all refs from the Git repository.""" | |
| 237 | |
| 238 | |
| 239 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None: | |
| 240 """Creates a HEAD reference in Git referring to the current HEAD.""" | |
| 241 # By default, we use '@', since that's what will be auto checked out. | |
| 242 current = b'@' | |
| 243 if current not in repo._bookmarks: | |
| 244 current = repo._bookmarks.active or current | |
| 245 | |
| 246 # We'll be moving this (possibly fake) bookmark into Git. | |
| 247 git_current = current | |
| 248 if current == b'@': | |
| 249 # @ is a special keyword in Git, so we can't use it as a bookmark. | |
| 250 git_current = at_name | |
| 251 git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current | |
| 252 if not dulwich.refs.check_ref_format(git_branch): | |
| 253 # We can't export this ref to Git. Give up. | |
| 254 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode()) | |
| 255 return | |
| 256 try: | |
| 257 # Maybe this is a real bookmark? | |
| 258 hgnode = repo._bookmarks[current] | |
| 259 except KeyError: | |
| 260 # Not a real bookmark. Assume we want the tip of the current branch. | |
| 261 branch = repo.dirstate.branch() | |
| 262 try: | |
| 263 hgnode = repo.branchtip(branch) | |
| 264 except hgerr.RepoLookupError: | |
| 265 # This branch somehow doesn't exist??? | |
| 266 ui.warn(f"{branch!r} doesn't seem to exist?".encode()) | |
| 267 return | |
| 268 hgsha = binascii.hexlify(hgnode) | |
| 269 gitsha = repo.githandler.map_git_get(hgsha) | |
| 270 if not gitsha: | |
| 271 # No Git SHA to match this Hg sha. Give up. | |
| 272 ui.warn(f'revision {hgsha!r} was not exported to Git'.encode()) | |
| 273 return | |
| 274 refs = repo.githandler.git.refs | |
| 275 refs.add_packed_refs({git_branch: gitsha}) | |
| 276 refs.set_symbolic_ref(b'HEAD', git_branch) | |
| 277 | |
| 278 | |
| 279 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: | |
| 280 """Exports to Git and sets up for serving. See ``_fix_refs``.""" | |
| 281 if not _is_gitty(repo): | |
| 282 return | |
| 283 _fix_refs(ui, repo) | |
| 284 | |
| 285 | |
| 286 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None: | |
| 287 """After a git export, fix up the refs. | |
| 288 | |
| 289 This ensures that there are no leftover refs from older, removed bookmarks | |
| 290 and that there is a proper HEAD set so that cloning works. | |
| 291 """ | |
| 292 refs = repo.githandler.git.refs | |
| 293 # dump to allkeys so we explicitly are iterating over a snapshot | |
| 294 # and not over something while we mutate | |
| 295 for ref in refs.allkeys(): | |
| 296 refs.remove_if_equals(ref, None) | |
| 297 repo.githandler.export_hg_tags() | |
| 298 repo.githandler.update_references() | |
| 299 default_branch_name = ui.config( | |
| 300 b'hggit-serve', b'default-branch', b'default' | |
| 301 ) | |
| 302 _set_head(ui, repo, default_branch_name) | |
| 303 | |
| 304 | |
| 305 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: | |
| 306 """Maybe exports the repository to get after we get new revs.""" | |
| 307 if not _is_gitty(repo): | |
| 308 return | |
| 309 auto_export = ui.config(b'hggit-serve', b'auto-export') | |
| 310 if auto_export == b'never': | |
| 311 return | |
| 312 if auto_export == b'always' or git_handler.has_gitrepo(repo): | |
| 313 if _is_importing(repo): | |
| 314 ui.note(b'currently importing revs from git; not exporting\n') | |
| 315 return | |
| 316 repo.githandler.export_commits() | |
| 317 _fix_refs(ui, repo) | |
| 318 | |
| 319 | |
| 320 # | |
| 321 # Interfacing with Mercurial | |
| 322 # | |
| 323 | |
| 324 __version__ = '0.2.0' | |
| 325 testedwith = b'7.1 7.2' | |
| 326 minimumhgversion = b'7.1' | |
| 327 | |
| 328 cmdtable: dict[bytes, object] = {} | |
| 329 | |
| 330 command = registrar.command(cmdtable) | |
| 331 | |
| 332 | |
| 333 def uisetup(_: hgui.ui) -> None: | |
| 334 extensions.wrapfunction( | |
| 335 wireprotoserver, 'handlewsgirequest', _handle_git_protocol | |
| 336 ) | |
| 337 | |
| 338 | |
| 339 def uipopulate(ui: hgui.ui) -> None: | |
| 340 # Fix up our tags after a Git export. | |
| 341 ui.setconfig( | |
| 342 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook | |
| 343 ) | |
| 344 # Whenever we get new revisions, export them to the Git repository. | |
| 345 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook) | |
| 346 # Don't step on ourselves when importing data from Git. | |
| 347 ui.setconfig( | |
| 348 b'hooks', | |
| 349 b'pre-git-import.__gitserve_suppress_export__', | |
| 350 lambda _, repo, **__: _importing_enter(repo), | |
| 351 ) | |
| 352 ui.setconfig( | |
| 353 b'hooks', | |
| 354 b'post-git-import.__gitserve_suppress_export__', | |
| 355 lambda _, repo, **__: _importing_exit(repo), | |
| 356 ) | |
| 357 | |
| 358 | |
| 359 __all__ = ( | |
| 360 '__version__', | |
| 361 'cmdtable', | |
| 362 'command', | |
| 363 'minimumhgversion', | |
| 364 'testedwith', | |
| 365 'uipopulate', | |
| 366 'uisetup', | |
| 367 ) |
