Mercurial > hg-git-serve
comparison src/git_serve/__init__.py @ 4:5ad58438318a
Fix issue where sometimes reads would be cut off.
Also format and fix other stuff.
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Sat, 14 Feb 2026 21:20:36 -0500 |
| parents | 189f4a0bc653 |
| children | c43ce246240b |
comparison
equal
deleted
inserted
replaced
| 3:189f4a0bc653 | 4:5ad58438318a |
|---|---|
| 5 import email.policy | 5 import email.policy |
| 6 import os.path | 6 import os.path |
| 7 import re | 7 import re |
| 8 import shutil | 8 import shutil |
| 9 import subprocess | 9 import subprocess |
| 10 import threading | |
| 11 import typing as t | 10 import typing as t |
| 12 | 11 |
| 13 import dulwich.refs | 12 import dulwich.refs |
| 14 import mercurial.error as hgerr | 13 import mercurial.error as hgerr |
| 15 from mercurial import extensions | 14 from mercurial import extensions |
| 29 PermissionCheck = t.Callable[ | 28 PermissionCheck = t.Callable[ |
| 30 [web_inner.requestcontext, hgreq.parsedrequest, bytes], | 29 [web_inner.requestcontext, hgreq.parsedrequest, bytes], |
| 31 None, | 30 None, |
| 32 ] | 31 ] |
| 33 GitPrelude = t.Sequence[bytes | str | os.PathLike] | 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') | |
| 34 | 38 |
| 35 | 39 |
| 36 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$') | 40 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$') |
| 37 """Environment variables that we need to pass to git-as-cgi.""" | 41 """Environment variables that we need to pass to git-as-cgi.""" |
| 38 | 42 |
| 54 | 58 |
| 55 | 59 |
| 56 def _parse_cgi_response( | 60 def _parse_cgi_response( |
| 57 output: t.IO[bytes], | 61 output: t.IO[bytes], |
| 58 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: | 62 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: |
| 63 """Parses a CGI response into a status, headers, and everyhting else.""" | |
| 59 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) | 64 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) |
| 60 while line := output.readline(): | 65 while line := output.readline(): |
| 61 if not line.rstrip(b'\r\n'): | 66 if not line.rstrip(b'\r\n'): |
| 62 # We've reached the end of the headers. | 67 # We've reached the end of the headers. |
| 63 # Leave the rest in the output for later. | 68 # Leave the rest in the output for later. |
| 70 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() | 75 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() |
| 71 } | 76 } |
| 72 return status, byte_headers, output | 77 return status, byte_headers, output |
| 73 | 78 |
| 74 | 79 |
| 75 _feeds = 0 | 80 def _handle_git_protocol( |
| 76 _feeder_lock = threading.Lock() | |
| 77 | |
| 78 | |
| 79 def feed_count() -> int: | |
| 80 global _feeds | |
| 81 with _feeder_lock: | |
| 82 _feeds += 1 | |
| 83 return _feeds | |
| 84 | |
| 85 | |
| 86 def git_binary(ui: hgui.ui) -> bytes: | |
| 87 return ui.config(b'git-serve', b'git', default=b'git') | |
| 88 | |
| 89 | |
| 90 def handle_git_protocol( | |
| 91 original: t.Callable[..., bool], | 81 original: t.Callable[..., bool], |
| 92 req_ctx: web_inner.requestcontext, | 82 req_ctx: web_inner.requestcontext, |
| 93 request: hgreq.parsedrequest, | 83 request: hgreq.parsedrequest, |
| 94 response: hgreq.wsgiresponse, | 84 response: hgreq.wsgiresponse, |
| 95 check_permission: PermissionCheck, | 85 check_permission: PermissionCheck, |
| 96 ) -> bool: | 86 ) -> bool: |
| 87 """Intercepts requests from Git, if needed.""" | |
| 97 repo = req_ctx.repo | 88 repo = req_ctx.repo |
| 98 if not is_gitty(repo) or b'git-protocol' not in request.headers: | 89 if not _is_gitty(repo) or b'git-protocol' not in request.headers: |
| 99 # We only handle Git requests; everything else is normal. | 90 # We only handle Git requests; everything else is normal. |
| 100 return original(req_ctx, request, response, check_permission) | 91 return original(req_ctx, request, response, check_permission) |
| 101 check_permission(req_ctx, request, b'pull') | 92 check_permission(req_ctx, request, b'pull') |
| 102 # If a request is git, we assume we should be the one handling it. | 93 # If a request is git, we assume we should be the one handling it. |
| 103 cgi_env = _build_git_environ(req_ctx, request) | 94 cgi_env = _build_git_environ(req_ctx, request) |
| 104 http_backend = req_ctx.repo.ui.configlist( | 95 http_backend = req_ctx.repo.ui.configlist( |
| 105 b'git-serve', b'http-backend', default=(b'git', b'http-backend') | 96 b'git-serve', b'http-backend', default=(b'git', b'http-backend') |
| 106 ) | 97 ) |
| 107 is_post = request.method == b'POST' | |
| 108 call = subprocess.Popen( | 98 call = subprocess.Popen( |
| 109 http_backend, | 99 http_backend, |
| 110 close_fds=True, | 100 close_fds=True, |
| 111 stdin=subprocess.PIPE if is_post else None, | 101 stdin=subprocess.PIPE, |
| 112 stdout=subprocess.PIPE, | 102 stdout=subprocess.PIPE, |
| 113 stderr=subprocess.DEVNULL, | 103 stderr=subprocess.DEVNULL, |
| 114 env=cgi_env, | 104 env=cgi_env, |
| 115 text=False, | 105 text=False, |
| 116 ) | 106 ) |
| 117 assert call.stdout | 107 assert call.stdout |
| 118 | 108 assert call.stdin |
| 119 def feed(): | 109 # Git will not start writing output until stdin is fully closed. |
| 120 try: | 110 with call.stdin: |
| 121 with call.stdin as stdin: | 111 shutil.copyfileobj(request.bodyfh, call.stdin) |
| 122 shutil.copyfileobj(request.bodyfh, stdin) | 112 |
| 123 except (OSError, BrokenPipeError): | |
| 124 pass # Expected; this just means it's closed. | |
| 125 | |
| 126 if is_post: | |
| 127 threading.Thread(target=feed, name=f'git-feeder-{feed_count()}').start() | |
| 128 status, headers, rest = _parse_cgi_response(call.stdout) | 113 status, headers, rest = _parse_cgi_response(call.stdout) |
| 129 response.status = status | 114 response.status = status |
| 130 for k, v in headers.items(): | 115 for k, v in headers.items(): |
| 131 response.headers[k] = v | 116 response.headers[k] = v |
| 132 | 117 |
| 133 def write_the_rest(): | 118 def write_the_rest(): |
| 134 with call, rest: | 119 with call, rest: |
| 135 while more := rest.read(64 * 1024): | 120 while more := rest.read(1024 * 1024): |
| 136 yield more | 121 yield more |
| 137 | 122 |
| 138 response.setbodygen(write_the_rest()) | 123 response.setbodygen(write_the_rest()) |
| 139 response.sendresponse() | 124 response.sendresponse() |
| 140 return True | 125 return True |
| 141 | 126 |
| 142 | 127 |
| 143 def clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: | 128 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: |
| 129 """Removes all refs from the Git repository.""" | |
| 144 for ref in refs.allkeys(): | 130 for ref in refs.allkeys(): |
| 145 refs.remove_if_equals(ref, None) | 131 refs.remove_if_equals(ref, None) |
| 146 | 132 |
| 147 | 133 |
| 148 def set_head(repo: GittyRepo) -> None: | 134 def _set_head(repo: GittyRepo) -> None: |
| 149 """Creates a HEAD reference in Git referring to the current HEAD.""" | 135 """Creates a HEAD reference in Git referring to the current HEAD.""" |
| 150 # By default, we use '@', since that's what will be auto checked out. | 136 # By default, we use '@', since that's what will be auto checked out. |
| 151 current = b'@' | 137 current = b'@' |
| 152 if current not in repo._bookmarks: | 138 if current not in repo._bookmarks: |
| 153 current = repo._bookmarks.active or current | 139 current = repo._bookmarks.active or current |
| 183 return | 169 return |
| 184 refs.add_packed_refs({git_branch: gitsha}) | 170 refs.add_packed_refs({git_branch: gitsha}) |
| 185 refs.set_symbolic_ref(b'HEAD', git_branch) | 171 refs.set_symbolic_ref(b'HEAD', git_branch) |
| 186 | 172 |
| 187 | 173 |
| 188 def export_hook(ui: hgui.ui, repo: GittyRepo, **__: object) -> None: | 174 def _export_hook(ui: hgui.ui, repo: GittyRepo, **__: object) -> None: |
| 175 """Exports to Git and sets up for serving.""" | |
| 189 never_export = ui.configbool(b'git-serve', b'never-export') | 176 never_export = ui.configbool(b'git-serve', b'never-export') |
| 190 if never_export: | 177 if never_export: |
| 191 return | 178 return |
| 192 always_export = ui.configbool(b'git-serve', b'always-export', False) | 179 always_export = ui.configbool(b'git-serve', b'always-export', False) |
| 193 if always_export or os.path.isdir(repo.githandler.gitdir): | 180 if always_export or os.path.isdir(repo.githandler.gitdir): |
| 194 export_repo(repo) | 181 _export_repo(repo) |
| 195 | 182 |
| 196 | 183 |
| 197 def export_repo(repo: GittyRepo) -> None: | 184 def _export_repo(repo: GittyRepo) -> None: |
| 198 clean_all_refs(repo.githandler.git.refs) | 185 """Do the actual exporting.""" |
| 186 _clean_all_refs(repo.githandler.git.refs) | |
| 199 repo.githandler.export_commits() | 187 repo.githandler.export_commits() |
| 200 set_head(repo) | 188 _set_head(repo) |
| 201 | |
| 202 | |
| 203 def is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: | |
| 204 return hasattr(repo, 'githandler') | |
| 205 | 189 |
| 206 | 190 |
| 207 # Interfacing with Mercurial | 191 # Interfacing with Mercurial |
| 208 | 192 |
| 209 __version__ = '0.1.1' | 193 __version__ = '0.1.2' |
| 210 | 194 |
| 211 cmdtable: dict[bytes, object] = {} | 195 cmdtable: dict[bytes, object] = {} |
| 212 | 196 |
| 213 command = registrar.command(cmdtable) | 197 command = registrar.command(cmdtable) |
| 214 | 198 |
| 215 | 199 |
| 216 @command(b'git-serve-export') | 200 @command(b'git-serve-export') |
| 217 def git_serve_export(_: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: | 201 def git_serve_export(_: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: |
| 218 if not is_gitty(repo): | 202 if not _is_gitty(repo): |
| 219 raise hgerr.Abort(b'this extension depends on the `hggit` extension') | 203 raise hgerr.Abort(b'this extension depends on the `hggit` extension') |
| 220 export_repo(repo) | 204 _export_repo(repo) |
| 221 | 205 |
| 222 | 206 |
| 223 def uisetup(_: hgui.ui) -> None: | 207 def uisetup(_: hgui.ui) -> None: |
| 224 extensions.wrapfunction( | 208 extensions.wrapfunction( |
| 225 wireprotoserver, 'handlewsgirequest', handle_git_protocol | 209 wireprotoserver, 'handlewsgirequest', _handle_git_protocol |
| 226 ) | 210 ) |
| 227 | 211 |
| 228 | 212 |
| 229 def reposetup(ui: hgui.ui, _: hgrepo.IRepo) -> None: | 213 def reposetup(ui: hgui.ui, _: hgrepo.IRepo) -> None: |
| 230 ui.setconfig(b'hooks', b'txnclose.__gitserve_internal__', export_hook) | 214 ui.setconfig(b'hooks', b'txnclose.__gitserve_internal__', _export_hook) |
| 231 | 215 |
| 232 | 216 |
| 233 __all__ = ('__version__', 'cmdtable', 'command', 'uisetup', 'reposetup') | 217 __all__ = ('__version__', 'cmdtable', 'command', 'uisetup', 'reposetup') |
