comparison src/git_serve.py @ 1:a39dd69b8972

Create a more-or-less real package and make it work (?)
author Paul Fisher <paul@pfish.zone>
date Sat, 14 Feb 2026 18:41:52 -0500
parents gitserve.py@c1dc9d21fa57
children
comparison
equal deleted inserted replaced
0:c1dc9d21fa57 1:a39dd69b8972
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')