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