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')