comparison src/hggit_serve.py @ 7:4f42fdbb25f2

rename to hggit_serve
author Paul Fisher <paul@pfish.zone>
date Sun, 15 Feb 2026 01:49:42 -0500
parents src/git_serve/__init__.py@7113e0ac3662
children fe3c9fae4d4d
comparison
equal deleted inserted replaced
6:7113e0ac3662 7:4f42fdbb25f2
1 from __future__ import annotations
2
3 import binascii
4 import email.parser
5 import email.policy
6 import os.path
7 import re
8 import shutil
9 import subprocess
10 import typing as t
11
12 import dulwich.refs
13 import mercurial.error as hgerr
14 from mercurial import extensions
15 from mercurial import registrar
16 from mercurial import wireprotoserver
17
18 if t.TYPE_CHECKING:
19 import hggit.git_handler
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: hggit.git_handler.GitHandler
27
28 PermissionCheck = t.Callable[
29 [web_inner.requestcontext, hgreq.parsedrequest, bytes],
30 None,
31 ]
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')
38
39
40 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
41 """Environment variables that we need to pass to git-as-cgi."""
42
43
44 def _build_git_environ(
45 req_ctx: web_inner.requestcontext,
46 request: hgreq.parsedrequest,
47 ) -> dict[bytes, bytes]:
48 """Builds the environment to be sent to Git to serve HTTP."""
49 fixed = {
50 k: v
51 for (k, v) in request.rawenv.items()
52 if isinstance(v, bytes) and _CGI_VAR.match(k)
53 }
54 fixed[b'GIT_HTTP_EXPORT_ALL'] = b'yes'
55 fixed[b'GIT_PROJECT_ROOT'] = req_ctx.repo.path
56 fixed[b'PATH_INFO'] = b'/git/' + request.dispatchpath
57 return fixed
58
59
60 def _parse_cgi_response(
61 output: t.IO[bytes],
62 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
63 """Parses a CGI response into a status, headers, and everyhting else."""
64 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
65 while line := output.readline():
66 if not line.rstrip(b'\r\n'):
67 # We've reached the end of the headers.
68 # Leave the rest in the output for later.
69 break
70 parser.feed(line)
71 msg = parser.close()
72 status = msg.get('Status', '200 OK I guess').encode('utf-8')
73 del msg['Status'] # this won't raise an exception
74 byte_headers = {
75 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
76 }
77 return status, byte_headers, output
78
79
80 def _handle_git_protocol(
81 original: t.Callable[..., bool],
82 req_ctx: web_inner.requestcontext,
83 request: hgreq.parsedrequest,
84 response: hgreq.wsgiresponse,
85 check_permission: PermissionCheck,
86 ) -> bool:
87 """Intercepts requests from Git, if needed."""
88 repo = req_ctx.repo
89 if not _is_gitty(repo) or b'git-protocol' not in request.headers:
90 # We only handle Git requests; everything else is normal.
91 return original(req_ctx, request, response, check_permission)
92 check_permission(req_ctx, request, b'pull')
93 # If a request is git, we assume we should be the one handling it.
94 cgi_env = _build_git_environ(req_ctx, request)
95 content_length_hdr = request.headers.get(b'content-length', b'0')
96 try:
97 content_length = int(content_length_hdr)
98 except ValueError as ve:
99 raise hgerr.InputError(
100 f'Invalid content-length {content_length!r}'.encode()
101 ) from ve
102 http_backend = req_ctx.repo.ui.configlist(
103 b'git-serve', b'http-backend', default=(b'git', b'http-backend')
104 )
105 call = subprocess.Popen(
106 http_backend,
107 close_fds=True,
108 stdin=subprocess.PIPE,
109 stdout=subprocess.PIPE,
110 stderr=subprocess.DEVNULL,
111 env=cgi_env,
112 text=False,
113 )
114 assert call.stdout
115 assert call.stdin
116 # Git will not start writing output until stdin is fully closed.
117 with call.stdin:
118 if content_length:
119 shutil.copyfileobj(
120 request.bodyfh, call.stdin, length=content_length
121 )
122
123 status, headers, rest = _parse_cgi_response(call.stdout)
124 response.status = status
125 for k, v in headers.items():
126 response.headers[k] = v
127
128 def write_the_rest():
129 with call, rest:
130 while more := rest.read(1024 * 1024):
131 yield more
132
133 response.setbodygen(write_the_rest())
134 response.sendresponse()
135 return True
136
137
138 def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
139 """Removes all refs from the Git repository."""
140 for ref in refs.allkeys():
141 refs.remove_if_equals(ref, None)
142
143
144 def _set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None:
145 """Creates a HEAD reference in Git referring to the current HEAD."""
146 # By default, we use '@', since that's what will be auto checked out.
147 current = b'@'
148 if current not in repo._bookmarks:
149 current = repo._bookmarks.active or current
150
151 # We'll be moving this (possibly fake) bookmark into Git.
152 git_current = current
153 if current == b'@':
154 # @ is a special keyword in Git, so we can't use it as a bookmark.
155 git_current = at_name
156 git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current
157 if not dulwich.refs.check_ref_format(git_branch):
158 # We can't export this ref to Git. Give up.
159 ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode())
160 return
161 try:
162 # Maybe this is a real bookmark?
163 hgsha = repo._bookmarks[current]
164 except KeyError:
165 # Not a real bookmark. Assume we want the tip of the current branch.
166 branch = repo.dirstate.branch()
167 try:
168 tip = repo.branchtip(branch)
169 except hgerr.RepoLookupError:
170 # This branch somehow doesn't exist???
171 ui.warn(f"{branch} doesn't seem to exist?".encode())
172 return
173 hgsha = binascii.hexlify(tip)
174 gitsha = repo.githandler.map_git_get(hgsha)
175 if not gitsha:
176 # No Git SHA to match this Hg sha. Give up.
177 ui.warn(f'revision {hgsha} was not exported to Git'.encode())
178 return
179 refs = repo.githandler.git.refs
180 refs.add_packed_refs({git_branch: gitsha})
181 refs.set_symbolic_ref(b'HEAD', git_branch)
182
183
184 def fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
185 """Exports to Git and sets up for serving."""
186 if not _is_gitty(repo):
187 return
188 _fix_refs(ui, repo)
189
190
191 def _fix_refs(ui: hgui.ui, repo: GittyRepo) -> None:
192 """After a git export, fix up the refs."""
193 _clean_all_refs(repo.githandler.git.refs)
194 repo.githandler.export_hg_tags()
195 repo.githandler.update_references()
196 default_branch_name = ui.config(
197 b'hggit-serve', b'default-branch', b'default'
198 )
199 _set_head(ui, repo, default_branch_name)
200
201
202 def export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None:
203 if not _is_gitty(repo):
204 return
205 auto_export = ui.config(b'hggit-serve', b'auto-export')
206 if auto_export == b'never':
207 return
208 if auto_export == b'always' or os.path.isdir(repo.githandler.gitdir):
209 repo.githandler.export_commits()
210 _fix_refs(ui, repo)
211
212
213 # Interfacing with Mercurial
214
215 __version__ = '0.1.5'
216 testedwith = b'7.1 7.2'
217
218 cmdtable: dict[bytes, object] = {}
219
220 command = registrar.command(cmdtable)
221
222
223 def uisetup(_: hgui.ui) -> None:
224 extensions.wrapfunction(
225 wireprotoserver, 'handlewsgirequest', _handle_git_protocol
226 )
227
228
229 def uipopulate(ui: hgui.ui) -> None:
230 ui.setconfig(
231 b'hooks', b'post-git-export.__gitserve_add_tag__', fix_refs_hook
232 )
233 ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', export_hook)
234
235
236 __all__ = (
237 '__version__',
238 'cmdtable',
239 'command',
240 'testedwith',
241 'uipopulate',
242 'uisetup',
243 )