pygitweb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygitweb/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitweb-0.1.0.dist-info/top_level.txt +1 -0
pygitweb/actions.py
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gitweb action handlers (git_*): summary, tree, blob, log, commit, tag, etc.
|
|
3
|
+
Ported from gitweb/gitweb.perl action handlers. Invoked by main.dispatch.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import html
|
|
10
|
+
import mimetypes
|
|
11
|
+
import os
|
|
12
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal, TypedDict
|
|
15
|
+
from urllib.parse import parse_qsl, quote, urlencode
|
|
16
|
+
|
|
17
|
+
import pygit2
|
|
18
|
+
from fastapi import HTTPException, Request
|
|
19
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
|
|
20
|
+
from python_ripgrep import PySortMode, PySortModeKind
|
|
21
|
+
from python_ripgrep import search as rg_search
|
|
22
|
+
|
|
23
|
+
from pygitweb.config import BLOB_LANG, settings
|
|
24
|
+
from pygitweb.formatting import age_string, sanitize, to_utf8
|
|
25
|
+
from pygitweb.git_helpers import (
|
|
26
|
+
get_blob_at_ref_path,
|
|
27
|
+
get_blob_unified_diff,
|
|
28
|
+
get_commit_history,
|
|
29
|
+
get_commit_unified_diff,
|
|
30
|
+
get_commits_in_range,
|
|
31
|
+
get_readme_at_ref_path,
|
|
32
|
+
get_tree_at_ref_path,
|
|
33
|
+
git_get_heads_list,
|
|
34
|
+
git_get_project_description,
|
|
35
|
+
git_get_references,
|
|
36
|
+
git_get_remotes_info,
|
|
37
|
+
git_get_tags_list,
|
|
38
|
+
git_get_type,
|
|
39
|
+
open_repo,
|
|
40
|
+
parse_commit,
|
|
41
|
+
parse_tag,
|
|
42
|
+
)
|
|
43
|
+
from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
|
|
44
|
+
from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
|
|
45
|
+
from pygitweb.projects import git_get_project_owner
|
|
46
|
+
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
|
|
47
|
+
from pygitweb.validation import is_valid_pathname, is_valid_ref_format
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BlameHunkInfo(TypedDict):
|
|
51
|
+
commit_id: str
|
|
52
|
+
commit_msg: str
|
|
53
|
+
commit_author: str
|
|
54
|
+
time: str
|
|
55
|
+
line_start: int
|
|
56
|
+
line_end: int
|
|
57
|
+
author_name: str
|
|
58
|
+
time_display: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _split_query_list(values: list[str]) -> list[str]:
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
for v in values:
|
|
64
|
+
for seg in (v or "").split(","):
|
|
65
|
+
s = seg.strip()
|
|
66
|
+
if s:
|
|
67
|
+
parts.append(s)
|
|
68
|
+
return parts
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_query_bool(value: str | None) -> bool | None:
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
v = value.strip().lower()
|
|
75
|
+
if v in ("1", "true", "yes", "on"):
|
|
76
|
+
return True
|
|
77
|
+
if v in ("0", "false", "no", "off"):
|
|
78
|
+
return False
|
|
79
|
+
raise HTTPException(status_code=400, detail=f"Invalid boolean: {value!r}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _project_worktree_root(project: str) -> Path:
|
|
83
|
+
root = (Path(settings.PROJECTROOT) / project).resolve()
|
|
84
|
+
if not root.exists() or not root.is_dir():
|
|
85
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
86
|
+
return root
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validated_search_paths(project_root: Path, raw_paths: list[str]) -> list[str]:
|
|
90
|
+
root = project_root.resolve()
|
|
91
|
+
if not raw_paths:
|
|
92
|
+
return [str(root)]
|
|
93
|
+
|
|
94
|
+
paths: list[str] = []
|
|
95
|
+
for raw in raw_paths:
|
|
96
|
+
p = (raw or "").strip()
|
|
97
|
+
if not p:
|
|
98
|
+
continue
|
|
99
|
+
candidate = Path(p)
|
|
100
|
+
if candidate.is_absolute() or ":" in p:
|
|
101
|
+
raise HTTPException(status_code=400, detail="path not relative to the project root")
|
|
102
|
+
abs_path = (root / p).resolve()
|
|
103
|
+
if not abs_path.is_relative_to(root):
|
|
104
|
+
raise HTTPException(status_code=400, detail="path not within the project root")
|
|
105
|
+
if not abs_path.exists():
|
|
106
|
+
raise HTTPException(status_code=400, detail=f"path entry does not exist: {p}")
|
|
107
|
+
paths.append(str(abs_path))
|
|
108
|
+
|
|
109
|
+
if not paths:
|
|
110
|
+
return [str(root)]
|
|
111
|
+
return paths
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_SORT_KINDS: tuple[str, ...] = ("Path", "LastModified", "LastAccessed", "Created")
|
|
115
|
+
|
|
116
|
+
SearchSortQuery = Literal[
|
|
117
|
+
"Path",
|
|
118
|
+
"-Path",
|
|
119
|
+
"LastModified",
|
|
120
|
+
"-LastModified",
|
|
121
|
+
"LastAccessed",
|
|
122
|
+
"-LastAccessed",
|
|
123
|
+
"Created",
|
|
124
|
+
"-Created",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
SearchFlagQuery = Literal["true", "false"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_search_sort(value: str | None) -> PySortMode | None:
|
|
131
|
+
if value is None or not value.strip():
|
|
132
|
+
return None
|
|
133
|
+
raw = value.strip()
|
|
134
|
+
reverse = False
|
|
135
|
+
if raw.startswith("-"):
|
|
136
|
+
reverse = True
|
|
137
|
+
raw = raw[1:].strip()
|
|
138
|
+
if raw not in _SORT_KINDS:
|
|
139
|
+
allowed = ", ".join(_SORT_KINDS)
|
|
140
|
+
raise HTTPException(status_code=400, detail=f"Invalid sort kind; allowed: {allowed}")
|
|
141
|
+
kind = getattr(PySortModeKind, raw)
|
|
142
|
+
return PySortMode(kind=kind, reverse=reverse)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pagination_url(request: Request, page: int, pagecount: int) -> str:
|
|
146
|
+
"""Build URL for a pagination page, preserving path and other query params."""
|
|
147
|
+
params = dict(parse_qsl(request.url.query, keep_blank_values=True))
|
|
148
|
+
params["page"] = str(page)
|
|
149
|
+
params["pagecount"] = str(pagecount)
|
|
150
|
+
return f"{request.url.path}?{urlencode(params)}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_pagination(
|
|
154
|
+
page: str | None,
|
|
155
|
+
pagecount: str | None,
|
|
156
|
+
) -> tuple[int, int]:
|
|
157
|
+
"""Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
|
|
158
|
+
p = 1
|
|
159
|
+
pc = 25
|
|
160
|
+
if page is not None:
|
|
161
|
+
try:
|
|
162
|
+
p = int(page)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
raise HTTPException(status_code=400, detail="page must be an integer") from e
|
|
165
|
+
if p < 1:
|
|
166
|
+
raise HTTPException(status_code=400, detail="page must be at least 1")
|
|
167
|
+
if pagecount is not None:
|
|
168
|
+
try:
|
|
169
|
+
pc = int(pagecount)
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
raise HTTPException(status_code=400, detail="pagecount must be an integer") from e
|
|
172
|
+
if pc < 1:
|
|
173
|
+
raise HTTPException(status_code=400, detail="pagecount must be at least 1")
|
|
174
|
+
if pc > 50:
|
|
175
|
+
raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
|
|
176
|
+
return p, pc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tree_url(project: str, h: str, f: str | None, a: str = "tree") -> str:
|
|
180
|
+
"""Build URL for tree or blob: /project/{project}?a=a&h=h&f=f with f quoted."""
|
|
181
|
+
q = f"a={a}&h={quote(h, safe='')}"
|
|
182
|
+
if f:
|
|
183
|
+
q += f"&f={quote(f, safe='/')}"
|
|
184
|
+
return f"/project/{project}?{q}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
|
|
188
|
+
"""Format epoch timestamp to readable date string.
|
|
189
|
+
tz_offset is in minutes (as returned by pygit2).
|
|
190
|
+
"""
|
|
191
|
+
if epoch is None:
|
|
192
|
+
return ""
|
|
193
|
+
try:
|
|
194
|
+
tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
|
|
195
|
+
dt = datetime.fromtimestamp(epoch, tz=tz)
|
|
196
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
197
|
+
except (ValueError, OSError):
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list:
|
|
202
|
+
"""Format a single commit as a table row."""
|
|
203
|
+
oid = commit.get("oid", "")
|
|
204
|
+
oid_short = oid[:7] if oid else ""
|
|
205
|
+
subject = commit.get("subject", "")
|
|
206
|
+
author = commit.get("author", "")
|
|
207
|
+
author_epoch = commit.get("author_epoch")
|
|
208
|
+
|
|
209
|
+
date_str = _format_date(author_epoch, commit.get("author_tz"))
|
|
210
|
+
age_sec = None
|
|
211
|
+
if author_epoch:
|
|
212
|
+
age_sec = datetime.now(UTC).timestamp() - author_epoch
|
|
213
|
+
age_str = age_string(age_sec) if age_sec > 0 else "right now"
|
|
214
|
+
else:
|
|
215
|
+
age_str = ""
|
|
216
|
+
|
|
217
|
+
commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
|
|
218
|
+
diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
|
|
219
|
+
author_display = jinja_escape(author) or "unknown"
|
|
220
|
+
|
|
221
|
+
if short:
|
|
222
|
+
return [
|
|
223
|
+
f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
|
|
224
|
+
jinja_escape(subject),
|
|
225
|
+
author_display,
|
|
226
|
+
jinja_escape(age_str),
|
|
227
|
+
f'<a href="{diff_link}">diff</a>',
|
|
228
|
+
]
|
|
229
|
+
return [
|
|
230
|
+
f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
|
|
231
|
+
jinja_escape(subject),
|
|
232
|
+
author_display,
|
|
233
|
+
jinja_escape(date_str),
|
|
234
|
+
jinja_escape(age_str),
|
|
235
|
+
f'<a href="{diff_link}">diff</a>',
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _render_pagination(
|
|
240
|
+
request: Request,
|
|
241
|
+
page: int,
|
|
242
|
+
pagecount: int,
|
|
243
|
+
has_prev: bool,
|
|
244
|
+
has_next: bool,
|
|
245
|
+
total_pages: int | None = None,
|
|
246
|
+
) -> str:
|
|
247
|
+
"""Render Tabler pagination HTML."""
|
|
248
|
+
prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
|
|
249
|
+
next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
|
|
250
|
+
page_links = None
|
|
251
|
+
if total_pages is not None and total_pages <= 20:
|
|
252
|
+
page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
|
|
253
|
+
elif total_pages is not None:
|
|
254
|
+
start = max(1, page - 2)
|
|
255
|
+
end = min(total_pages, page + 2)
|
|
256
|
+
page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
|
|
257
|
+
if start > 1:
|
|
258
|
+
page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
|
|
259
|
+
if end < total_pages:
|
|
260
|
+
page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
|
|
261
|
+
return env.get_template("pagination.html").render(
|
|
262
|
+
current_page=page,
|
|
263
|
+
pagecount=pagecount,
|
|
264
|
+
has_prev=has_prev,
|
|
265
|
+
has_next=has_next,
|
|
266
|
+
prev_url=prev_url,
|
|
267
|
+
next_url=next_url,
|
|
268
|
+
total_pages=total_pages,
|
|
269
|
+
page_links=page_links,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _render_collapsible_section(
|
|
274
|
+
title: str,
|
|
275
|
+
body: str,
|
|
276
|
+
*,
|
|
277
|
+
open_default: bool = True,
|
|
278
|
+
flush_table: bool = False,
|
|
279
|
+
) -> str:
|
|
280
|
+
open_attr = " open" if open_default else ""
|
|
281
|
+
body_class = "card-body p-0" if flush_table else "card-body"
|
|
282
|
+
return (
|
|
283
|
+
f'<details class="summary-section card mt-3"{open_attr}>'
|
|
284
|
+
f'<summary class="card-header summary-section-toggle">'
|
|
285
|
+
f'<h2 class="card-title mb-0">{jinja_escape(title)}</h2>'
|
|
286
|
+
f"</summary>"
|
|
287
|
+
f'<div class="{body_class}">{body}</div>'
|
|
288
|
+
f"</details>"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _render_tree_table(project: str, ref: str | None, f: str | None) -> str:
|
|
293
|
+
"""Return an HTML table listing files and directories at ref/path."""
|
|
294
|
+
if f is not None and not is_valid_pathname(f):
|
|
295
|
+
return ""
|
|
296
|
+
result = get_tree_at_ref_path(project, ref, f)
|
|
297
|
+
if not result:
|
|
298
|
+
return '<p class="text-muted mb-0">No files found.</p>'
|
|
299
|
+
tree, ref_oid = result
|
|
300
|
+
entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
|
|
301
|
+
dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
|
|
302
|
+
blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
|
|
303
|
+
rows: list[list[str]] = []
|
|
304
|
+
for name, _, _ in dirs:
|
|
305
|
+
sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
|
|
306
|
+
link = _tree_url(project, ref_oid, sub_path)
|
|
307
|
+
rows.append([f'<a href="{link}">{jinja_escape(name)}/</a>', "tree", ""])
|
|
308
|
+
for name, _, _ in blobs:
|
|
309
|
+
sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
|
|
310
|
+
link = _tree_url(project, ref_oid, sub_path, a="blob")
|
|
311
|
+
blame_link = _tree_url(project, ref_oid, sub_path, a="blame")
|
|
312
|
+
rows.append([
|
|
313
|
+
f'<a href="{link}">{jinja_escape(name)}</a>',
|
|
314
|
+
"blob",
|
|
315
|
+
f'<a href="{blame_link}">blame</a>',
|
|
316
|
+
])
|
|
317
|
+
if not rows:
|
|
318
|
+
return '<p class="text-muted mb-0">Empty directory.</p>'
|
|
319
|
+
return env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _render_readme_card(
|
|
323
|
+
project: str,
|
|
324
|
+
ref_oid: str,
|
|
325
|
+
readme_filename: str,
|
|
326
|
+
readme_content: str,
|
|
327
|
+
blob_dir: str = "",
|
|
328
|
+
) -> str:
|
|
329
|
+
"""Return HTML for the README section (and script for markdown).
|
|
330
|
+
|
|
331
|
+
blob_dir is the current tree path for relative links.
|
|
332
|
+
"""
|
|
333
|
+
is_markdown = readme_filename.lower().endswith(".md")
|
|
334
|
+
blob_base = f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f="
|
|
335
|
+
blob_base_attr = blob_base.replace("&", "&").replace('"', """)
|
|
336
|
+
blob_dir_attr = blob_dir.replace("&", "&").replace('"', """) if blob_dir else ""
|
|
337
|
+
if is_markdown:
|
|
338
|
+
readme_b64 = base64.b64encode(readme_content.encode("utf-8")).decode("ascii")
|
|
339
|
+
body = (
|
|
340
|
+
'<div id="readme-container" class="markdown-body" data-readme-b64="'
|
|
341
|
+
+ readme_b64
|
|
342
|
+
+ '" data-blob-base="'
|
|
343
|
+
+ blob_base_attr
|
|
344
|
+
+ '"'
|
|
345
|
+
)
|
|
346
|
+
if blob_dir_attr:
|
|
347
|
+
body += ' data-blob-dir="' + blob_dir_attr + '"'
|
|
348
|
+
body += '></div><script src="/static/readme-render.js"></script>'
|
|
349
|
+
return _render_collapsible_section("README", body)
|
|
350
|
+
body = '<pre class="readme-plain"><code>' + (jinja_escape(sanitize(readme_content)) or "") + "</code></pre>"
|
|
351
|
+
return _render_collapsible_section("README", body)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _commit_unified_diff_or_raise(project: str, h: str) -> tuple[str, str]:
|
|
355
|
+
"""Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
|
|
356
|
+
if not h:
|
|
357
|
+
raise HTTPException(status_code=400, detail="Commit hash (h) required")
|
|
358
|
+
if not is_valid_ref_format(h):
|
|
359
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
360
|
+
try:
|
|
361
|
+
return get_commit_unified_diff(project, h)
|
|
362
|
+
except (KeyError, pygit2.GitError, OSError, ValueError) as e:
|
|
363
|
+
raise HTTPException(status_code=404, detail="Commit not found") from e
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class SummaryRefOption(TypedDict):
|
|
367
|
+
label: str
|
|
368
|
+
value: str
|
|
369
|
+
kind: str
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class SummaryRefState(TypedDict):
|
|
373
|
+
ref: str
|
|
374
|
+
display: str
|
|
375
|
+
commit_href: str
|
|
376
|
+
browse_href: str
|
|
377
|
+
log_href: str
|
|
378
|
+
shortlog_href: str
|
|
379
|
+
tree_html: str
|
|
380
|
+
readme_html: str
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def summary_ref_options(project: str) -> list[SummaryRefOption]:
|
|
384
|
+
options: list[SummaryRefOption] = []
|
|
385
|
+
for name, ref, _oid in git_get_heads_list(project):
|
|
386
|
+
options.append({"label": name, "value": ref, "kind": "branch"})
|
|
387
|
+
for name, ref, _oid in git_get_tags_list(project):
|
|
388
|
+
options.append({"label": name, "value": ref, "kind": "tag"})
|
|
389
|
+
return options
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def summary_ref_state(project: str, requested_ref: str | None) -> SummaryRefState:
|
|
393
|
+
ref = requested_ref or "HEAD"
|
|
394
|
+
commit_oid = ""
|
|
395
|
+
browse_ref = ref
|
|
396
|
+
try:
|
|
397
|
+
repo = open_repo(project)
|
|
398
|
+
obj = repo.revparse_single(ref)
|
|
399
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
400
|
+
ref = "HEAD"
|
|
401
|
+
try:
|
|
402
|
+
repo = open_repo(project)
|
|
403
|
+
obj = repo.revparse_single(ref)
|
|
404
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
405
|
+
obj = None
|
|
406
|
+
if isinstance(obj, pygit2.Commit):
|
|
407
|
+
commit_oid = str(obj.id)
|
|
408
|
+
elif isinstance(obj, pygit2.Tag):
|
|
409
|
+
try:
|
|
410
|
+
target_obj = repo[obj.target]
|
|
411
|
+
except (KeyError, pygit2.GitError):
|
|
412
|
+
target_obj = None
|
|
413
|
+
if isinstance(target_obj, pygit2.Commit):
|
|
414
|
+
commit_oid = str(target_obj.id)
|
|
415
|
+
elif target_obj is not None:
|
|
416
|
+
browse_ref = str(target_obj.id)
|
|
417
|
+
tree_html = _render_tree_table(project, ref, None)
|
|
418
|
+
readme_html = ""
|
|
419
|
+
readme = get_readme_at_ref_path(project, ref, "")
|
|
420
|
+
if readme:
|
|
421
|
+
readme_filename, readme_content = readme
|
|
422
|
+
readme_html = _render_readme_card(project, ref, readme_filename, readme_content, "")
|
|
423
|
+
log_ref = commit_oid or ref
|
|
424
|
+
return {
|
|
425
|
+
"ref": ref,
|
|
426
|
+
"display": (commit_oid[:7] if commit_oid else ref) or "N/A",
|
|
427
|
+
"commit_href": f"/project/{project}?a=commit&h={quote(commit_oid or ref, safe='')}",
|
|
428
|
+
"browse_href": f"/project/{project}?a=tree&h={quote(browse_ref, safe='')}",
|
|
429
|
+
"log_href": f"/project/{project}?a=log&h={quote(log_ref, safe='')}",
|
|
430
|
+
"shortlog_href": f"/project/{project}?a=shortlog&h={quote(log_ref, safe='')}",
|
|
431
|
+
"tree_html": tree_html,
|
|
432
|
+
"readme_html": readme_html,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------- Action handlers ----------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def git_object(project: str, h: str | None) -> Response:
|
|
440
|
+
"""Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
|
|
441
|
+
if not h:
|
|
442
|
+
raise HTTPException(status_code=400, detail="Object hash (h) required")
|
|
443
|
+
if not is_valid_ref_format(h):
|
|
444
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
445
|
+
obj_type = git_get_type(project, h)
|
|
446
|
+
if not obj_type:
|
|
447
|
+
raise HTTPException(status_code=404, detail="Object not found")
|
|
448
|
+
if obj_type == "commit":
|
|
449
|
+
return git_commit(project, h)
|
|
450
|
+
if obj_type == "tree":
|
|
451
|
+
return git_tree(project, h, None)
|
|
452
|
+
if obj_type == "tag":
|
|
453
|
+
return git_tag(project, h)
|
|
454
|
+
if obj_type == "blob":
|
|
455
|
+
try:
|
|
456
|
+
repo = open_repo(project)
|
|
457
|
+
obj = repo.revparse_single(h)
|
|
458
|
+
except (KeyError, pygit2.GitError, OSError) as e:
|
|
459
|
+
raise HTTPException(status_code=404, detail="Object not found") from e
|
|
460
|
+
if not isinstance(obj, pygit2.Blob):
|
|
461
|
+
raise HTTPException(status_code=404, detail="Not a blob")
|
|
462
|
+
data = obj.data
|
|
463
|
+
text = to_utf8(data) or ""
|
|
464
|
+
body = jinja_escape(sanitize(text) or "") or ""
|
|
465
|
+
lines = text.split("\n")
|
|
466
|
+
num_lines = max(1, len(lines))
|
|
467
|
+
line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
|
|
468
|
+
oid_short = str(obj.id)[:7]
|
|
469
|
+
blob_html = (
|
|
470
|
+
f'<div class="blob-view">'
|
|
471
|
+
f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
|
|
472
|
+
f'<pre class="blob-content"><code class="hljs">{body}</code></pre>'
|
|
473
|
+
f"</div>"
|
|
474
|
+
)
|
|
475
|
+
pre = PREAMBLE.render(
|
|
476
|
+
title=f"Blob {oid_short} - {project}",
|
|
477
|
+
site_name=settings.SITE_NAME,
|
|
478
|
+
)
|
|
479
|
+
return HTMLResponse(
|
|
480
|
+
f"{pre}<h1>Blob {jinja_escape(oid_short)}</h1>"
|
|
481
|
+
f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
|
|
482
|
+
f"{blob_html}{POSTAMBLE}"
|
|
483
|
+
)
|
|
484
|
+
raise HTTPException(status_code=404, detail="Unknown object type")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def git_search(
|
|
488
|
+
project: str,
|
|
489
|
+
search_patterns: str | None,
|
|
490
|
+
search_paths: str | None,
|
|
491
|
+
search_globs: str | None,
|
|
492
|
+
search_heading: SearchFlagQuery | None,
|
|
493
|
+
search_multiline: SearchFlagQuery | None,
|
|
494
|
+
search_sort: SearchSortQuery | None,
|
|
495
|
+
search_max_count: str | None,
|
|
496
|
+
) -> JSONResponse:
|
|
497
|
+
patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
|
|
498
|
+
if not patterns:
|
|
499
|
+
raise HTTPException(status_code=400, detail="patterns is required (repeat ?patterns= or use comma-separated)")
|
|
500
|
+
|
|
501
|
+
project_root = _project_worktree_root(project)
|
|
502
|
+
paths = _validated_search_paths(project_root, _split_query_list([search_paths] if search_paths is not None else []))
|
|
503
|
+
|
|
504
|
+
globs = _split_query_list([search_globs] if search_globs is not None else [])
|
|
505
|
+
heading = _parse_query_bool(search_heading)
|
|
506
|
+
multiline = _parse_query_bool(search_multiline)
|
|
507
|
+
sort = _parse_search_sort(search_sort)
|
|
508
|
+
|
|
509
|
+
max_count: int = 25
|
|
510
|
+
if search_max_count is not None and search_max_count.strip() != "":
|
|
511
|
+
try:
|
|
512
|
+
max_count = int(search_max_count)
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
raise HTTPException(status_code=400, detail="max_count must be an integer") from e
|
|
515
|
+
if max_count < 1:
|
|
516
|
+
raise HTTPException(status_code=400, detail="max_count must be at least 1")
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
results = rg_search(
|
|
520
|
+
patterns=patterns,
|
|
521
|
+
paths=paths,
|
|
522
|
+
globs=(globs or None),
|
|
523
|
+
heading=heading,
|
|
524
|
+
sort=sort,
|
|
525
|
+
max_count=max_count,
|
|
526
|
+
multiline=multiline,
|
|
527
|
+
)
|
|
528
|
+
except Exception as e:
|
|
529
|
+
raise HTTPException(status_code=500, detail=f"Search failed: {e}") from e
|
|
530
|
+
|
|
531
|
+
root_str = str(project_root.resolve())
|
|
532
|
+
rel_paths: list[str] = []
|
|
533
|
+
for p in paths:
|
|
534
|
+
abs_p = str(Path(p).resolve())
|
|
535
|
+
rel = os.path.relpath(abs_p, root_str)
|
|
536
|
+
rel_paths.append("" if rel == "." else rel.replace(os.sep, "/"))
|
|
537
|
+
|
|
538
|
+
return JSONResponse({
|
|
539
|
+
"project": project,
|
|
540
|
+
"project_root": root_str,
|
|
541
|
+
"patterns": patterns,
|
|
542
|
+
"paths": rel_paths,
|
|
543
|
+
"globs": globs,
|
|
544
|
+
"heading": heading,
|
|
545
|
+
"sort": (search_sort or None),
|
|
546
|
+
"max_count": max_count,
|
|
547
|
+
"multiline": multiline,
|
|
548
|
+
"results": results,
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def git_search_page(project: str) -> HTMLResponse:
|
|
553
|
+
"""Per-project search UI page. Calls /project/{project}?a=search via fetch."""
|
|
554
|
+
project_enc = quote(project, safe="/")
|
|
555
|
+
pre = PREAMBLE.render(
|
|
556
|
+
title=f"Search - {project}",
|
|
557
|
+
site_name=settings.SITE_NAME,
|
|
558
|
+
)
|
|
559
|
+
body = env.get_template("search.html").render(
|
|
560
|
+
project=project,
|
|
561
|
+
project_enc=project_enc,
|
|
562
|
+
project_url=f"/project/{project_enc}",
|
|
563
|
+
search_api_url=f"/project/{project_enc}",
|
|
564
|
+
)
|
|
565
|
+
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _hooks_summary_cell(project: str, project_enc: str) -> str:
|
|
569
|
+
"""Render the Hooks row: installed samples with versions and an install dropdown."""
|
|
570
|
+
tpl = env.get_template("hooks_summary_cell.html")
|
|
571
|
+
try:
|
|
572
|
+
installed = list_installed_hooks(project)
|
|
573
|
+
available = list_installable_hooks(project)
|
|
574
|
+
except (KeyError, OSError):
|
|
575
|
+
return tpl.render(mode="unavailable")
|
|
576
|
+
return tpl.render(
|
|
577
|
+
mode="manage",
|
|
578
|
+
project_enc=project_enc,
|
|
579
|
+
installed=installed,
|
|
580
|
+
available=available,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _merge_request_summary_cell(project: str) -> str:
|
|
585
|
+
heads = git_get_heads_list(project)
|
|
586
|
+
if not heads:
|
|
587
|
+
return '<span class="text-muted">No branches.</span>'
|
|
588
|
+
refs = [h[1] for h in heads]
|
|
589
|
+
branches = [{"short": h[0], "ref": h[1]} for h in heads]
|
|
590
|
+
if "refs/heads/main" in refs:
|
|
591
|
+
ours_default_ref = "refs/heads/main"
|
|
592
|
+
elif "refs/heads/master" in refs:
|
|
593
|
+
ours_default_ref = "refs/heads/master"
|
|
594
|
+
else:
|
|
595
|
+
ours_default_ref = refs[0]
|
|
596
|
+
theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
|
|
597
|
+
return env.get_template("merge_request_summary_cell.html").render(
|
|
598
|
+
project=project,
|
|
599
|
+
branches=branches,
|
|
600
|
+
theirs_default_ref=theirs_default_ref,
|
|
601
|
+
ours_default_ref=ours_default_ref,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTMLResponse:
|
|
606
|
+
"""Project summary page. Port of git_summary."""
|
|
607
|
+
descr = git_get_project_description(project) or ""
|
|
608
|
+
owner = git_get_project_owner(project) or ""
|
|
609
|
+
initial_ref_state = summary_ref_state(project, "HEAD")
|
|
610
|
+
project_enc = quote(project, safe="/")
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
board_refs = git_get_references(project, "refs/tags/boards")
|
|
614
|
+
has_boards = len(board_refs) > 0
|
|
615
|
+
except Exception:
|
|
616
|
+
has_boards = False
|
|
617
|
+
if has_boards:
|
|
618
|
+
board_value = f'<a href="/project/{project_enc}/board/">project board</a>'
|
|
619
|
+
else:
|
|
620
|
+
grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
|
|
621
|
+
proj_q = quote(project, safe="")
|
|
622
|
+
board_value = f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
|
|
623
|
+
|
|
624
|
+
rows: list[list[str]] = [
|
|
625
|
+
["Owner", jinja_escape(owner) or ""],
|
|
626
|
+
[
|
|
627
|
+
(
|
|
628
|
+
'<label for="summary-ref-select">Ref</label> '
|
|
629
|
+
'<select id="summary-ref-select">'
|
|
630
|
+
'<option value="HEAD" selected>HEAD</option>'
|
|
631
|
+
"</select>"
|
|
632
|
+
),
|
|
633
|
+
(
|
|
634
|
+
f'<a id="summary-ref-commit-link" href="{initial_ref_state["commit_href"]}">'
|
|
635
|
+
f"{initial_ref_state['display']}</a>: "
|
|
636
|
+
f'<a id="summary-ref-browse-link" href="{initial_ref_state["browse_href"]}">browse</a> - '
|
|
637
|
+
f'<a id="summary-ref-log-link" href="{initial_ref_state["log_href"]}">log</a> - '
|
|
638
|
+
f'<a id="summary-ref-shortlog-link" href="{initial_ref_state["shortlog_href"]}">shortlog</a>'
|
|
639
|
+
),
|
|
640
|
+
],
|
|
641
|
+
["Branches", f"<a href='/project/{project}?a=heads'>view branches</a>"],
|
|
642
|
+
["Merge request", _merge_request_summary_cell(project)],
|
|
643
|
+
["Tags", f"<a href='/project/{project}?a=tags'>view tags</a>"],
|
|
644
|
+
["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
|
|
645
|
+
["Board", board_value],
|
|
646
|
+
["Search", f"<a href='/project/{project_enc}/search'>search files</a>"],
|
|
647
|
+
["Hooks", _hooks_summary_cell(project, project_enc)],
|
|
648
|
+
]
|
|
649
|
+
if extra_rows:
|
|
650
|
+
rows.extend(extra_rows)
|
|
651
|
+
table = env.get_template("table.html").render(
|
|
652
|
+
cols=["Field", "Value"],
|
|
653
|
+
rows=rows,
|
|
654
|
+
)
|
|
655
|
+
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - {project}", site_name=settings.SITE_NAME)
|
|
656
|
+
body_parts = [f"{pre}<h1>{jinja_escape(project)}</h1>"]
|
|
657
|
+
if descr:
|
|
658
|
+
body_parts.append(f"<p>{jinja_escape(descr)}</p>")
|
|
659
|
+
body_parts.append(_render_collapsible_section("Project", table, flush_table=True))
|
|
660
|
+
files_body = (
|
|
661
|
+
f'<div id="summary-tree-container" class="summary-tree-table">'
|
|
662
|
+
f"{initial_ref_state['tree_html']}</div>"
|
|
663
|
+
f'<p class="px-3 pb-3 mb-0"><a id="summary-tree-browse-link" href="{initial_ref_state["browse_href"]}">'
|
|
664
|
+
f"Browse full tree</a></p>"
|
|
665
|
+
)
|
|
666
|
+
body_parts.append(_render_collapsible_section("Files", files_body))
|
|
667
|
+
body_parts.append(f'<div id="summary-readme-container" data-project="{jinja_escape(project)}">')
|
|
668
|
+
body_parts.append(f"{initial_ref_state['readme_html']}</div>")
|
|
669
|
+
body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')
|
|
670
|
+
body_parts.append('<script src="/static/hook-install.js"></script>')
|
|
671
|
+
|
|
672
|
+
body_parts.append(POSTAMBLE)
|
|
673
|
+
return HTMLResponse("".join(body_parts))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def git_remotes(project: str) -> HTMLResponse:
|
|
677
|
+
"""Remotes page: list configured remotes (name, url, push_url)."""
|
|
678
|
+
remotes = git_get_remotes_info(project)
|
|
679
|
+
if not remotes:
|
|
680
|
+
pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
|
|
681
|
+
return HTMLResponse(f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}")
|
|
682
|
+
rows = []
|
|
683
|
+
for r in remotes:
|
|
684
|
+
name = jinja_escape(r["name"])
|
|
685
|
+
url = jinja_escape(r["url"] or "—")
|
|
686
|
+
push_url = jinja_escape(r["push_url"] or "—")
|
|
687
|
+
rows.append([name, url, push_url])
|
|
688
|
+
table = env.get_template("table.html").render(
|
|
689
|
+
cols=["Name", "URL", "Push URL"],
|
|
690
|
+
rows=rows,
|
|
691
|
+
)
|
|
692
|
+
pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
|
|
693
|
+
return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
|
|
697
|
+
"""Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
|
|
698
|
+
if not f:
|
|
699
|
+
raise HTTPException(status_code=400, detail="File path (f) required")
|
|
700
|
+
if not is_valid_pathname(f):
|
|
701
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
702
|
+
result = get_blob_at_ref_path(project, h, f)
|
|
703
|
+
if not result:
|
|
704
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
705
|
+
blob, _ = result
|
|
706
|
+
data = blob.data
|
|
707
|
+
if raw:
|
|
708
|
+
media_type, _ = mimetypes.guess_type(f.split("/")[-1])
|
|
709
|
+
if media_type is None:
|
|
710
|
+
try:
|
|
711
|
+
data.decode("utf-8")
|
|
712
|
+
media_type = "text/plain; charset=utf-8"
|
|
713
|
+
except UnicodeDecodeError:
|
|
714
|
+
media_type = "application/octet-stream"
|
|
715
|
+
return Response(content=data, media_type=media_type)
|
|
716
|
+
text = to_utf8(data) or ""
|
|
717
|
+
body = jinja_escape(sanitize(text) or "") or ""
|
|
718
|
+
lines = text.split("\n")
|
|
719
|
+
num_lines = max(1, len(lines))
|
|
720
|
+
line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
|
|
721
|
+
ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
|
|
722
|
+
lang = BLOB_LANG.get(ext, "")
|
|
723
|
+
lang_attr = f" language-{lang}" if lang else ""
|
|
724
|
+
blob_html = (
|
|
725
|
+
f'<div class="blob-view">'
|
|
726
|
+
f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
|
|
727
|
+
f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
|
|
728
|
+
f"</div>"
|
|
729
|
+
)
|
|
730
|
+
blob_script = '<script src="/static/blob-view.js"></script>'
|
|
731
|
+
pre = PREAMBLE.render(title=f"{f} - {project}", site_name=settings.SITE_NAME)
|
|
732
|
+
return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
|
|
736
|
+
return hunk.final_start_line_number + hunk.lines_in_hunk - 1
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _merge_blame_hunks(repo: pygit2.Repository, blame: pygit2.Blame) -> list[BlameHunkInfo]:
|
|
740
|
+
out: list[BlameHunkInfo] = []
|
|
741
|
+
i = 0
|
|
742
|
+
n = len(blame)
|
|
743
|
+
while i < n:
|
|
744
|
+
hunk = blame[i]
|
|
745
|
+
cid = str(hunk.final_commit_id)
|
|
746
|
+
line_start = hunk.final_start_line_number
|
|
747
|
+
line_end = _blame_hunk_end_line(hunk)
|
|
748
|
+
j = i + 1
|
|
749
|
+
while j < n:
|
|
750
|
+
nh = blame[j]
|
|
751
|
+
if str(nh.final_commit_id) != cid:
|
|
752
|
+
break
|
|
753
|
+
if nh.final_start_line_number != line_end + 1:
|
|
754
|
+
break
|
|
755
|
+
line_end = _blame_hunk_end_line(nh)
|
|
756
|
+
j += 1
|
|
757
|
+
try:
|
|
758
|
+
bc = repo[cid].peel(pygit2.Commit)
|
|
759
|
+
except (KeyError, pygit2.GitError, ValueError) as e:
|
|
760
|
+
raise HTTPException(status_code=500, detail=f"Missing blame commit {cid}") from e
|
|
761
|
+
auth = bc.author
|
|
762
|
+
tzinfo = timezone(timedelta(minutes=auth.offset))
|
|
763
|
+
dt = datetime.fromtimestamp(float(auth.time), tz=tzinfo)
|
|
764
|
+
out.append({
|
|
765
|
+
"commit_id": cid,
|
|
766
|
+
"commit_msg": bc.message.rstrip("\n"),
|
|
767
|
+
"commit_author": f"{auth.name} <{auth.email}>",
|
|
768
|
+
"time": dt.isoformat(),
|
|
769
|
+
"line_start": line_start,
|
|
770
|
+
"line_end": line_end,
|
|
771
|
+
"author_name": auth.name,
|
|
772
|
+
"time_display": _format_date(auth.time, auth.offset),
|
|
773
|
+
})
|
|
774
|
+
i = j
|
|
775
|
+
return out
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _blame_for_file(project: str, h: str | None, f: str) -> list[BlameHunkInfo]:
|
|
779
|
+
if not is_valid_pathname(f):
|
|
780
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
781
|
+
if not get_blob_at_ref_path(project, h, f):
|
|
782
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
783
|
+
repo = open_repo(project)
|
|
784
|
+
base_ref = h or (str(repo.head.target) if repo.head else None)
|
|
785
|
+
if not base_ref:
|
|
786
|
+
raise HTTPException(status_code=404, detail="No revision to blame")
|
|
787
|
+
try:
|
|
788
|
+
commit_tip = repo.revparse_single(base_ref).peel(pygit2.Commit)
|
|
789
|
+
except (KeyError, pygit2.GitError, ValueError) as e:
|
|
790
|
+
raise HTTPException(status_code=400, detail="Invalid revision for blame") from e
|
|
791
|
+
path_clean = f.strip("/")
|
|
792
|
+
try:
|
|
793
|
+
blame = repo.blame(path_clean, newest_commit=commit_tip.id)
|
|
794
|
+
except (pygit2.GitError, OSError) as e:
|
|
795
|
+
raise HTTPException(status_code=400, detail=f"Blame failed: {e}") from e
|
|
796
|
+
return _merge_blame_hunks(repo, blame)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _render_blame_commits_column(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> str:
|
|
800
|
+
hunk_by_start = {h["line_start"]: h for h in hunks}
|
|
801
|
+
lines: list[str] = []
|
|
802
|
+
for line_no in range(1, num_lines + 1):
|
|
803
|
+
hunk = hunk_by_start.get(line_no)
|
|
804
|
+
if hunk is None:
|
|
805
|
+
lines.append("")
|
|
806
|
+
continue
|
|
807
|
+
cid = hunk["commit_id"]
|
|
808
|
+
oid_short = jinja_escape(cid[:7]) or ""
|
|
809
|
+
commit_link = f"/project/{project}?a=commit&h={quote(cid, safe='')}"
|
|
810
|
+
tooltip = html.escape(f"{hunk['author_name']} — {hunk['time_display']}", quote=True)
|
|
811
|
+
lines.append(f'<a href="{commit_link}" class="blame-commit-link" title="{tooltip}">{oid_short}</a>')
|
|
812
|
+
return "\n".join(lines)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
|
|
816
|
+
"""Blame page: file content with per-range commit links and line numbers."""
|
|
817
|
+
if not f:
|
|
818
|
+
raise HTTPException(status_code=400, detail="File path (f) required")
|
|
819
|
+
result = get_blob_at_ref_path(project, h, f)
|
|
820
|
+
if not result:
|
|
821
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
822
|
+
blob, _ref_oid = result
|
|
823
|
+
hunks = _blame_for_file(project, h, f)
|
|
824
|
+
text = to_utf8(blob.data) or ""
|
|
825
|
+
body = jinja_escape(sanitize(text) or "") or ""
|
|
826
|
+
lines = text.split("\n")
|
|
827
|
+
num_lines = max(1, len(lines))
|
|
828
|
+
line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
|
|
829
|
+
blame_commits = _render_blame_commits_column(project, hunks, num_lines)
|
|
830
|
+
ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
|
|
831
|
+
lang = BLOB_LANG.get(ext, "")
|
|
832
|
+
lang_attr = f" language-{lang}" if lang else ""
|
|
833
|
+
blame_html = (
|
|
834
|
+
f'<div class="blob-view blame-view">'
|
|
835
|
+
f'<div class="blame-commits" aria-label="Blame commits">{blame_commits}</div>'
|
|
836
|
+
f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
|
|
837
|
+
f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
|
|
838
|
+
f"</div>"
|
|
839
|
+
)
|
|
840
|
+
blame_script = '<script src="/static/blob-view.js"></script>'
|
|
841
|
+
pre = PREAMBLE.render(title=f"Blame {f} - {project}", site_name=settings.SITE_NAME)
|
|
842
|
+
return HTMLResponse(f"{pre}{blame_html}{blame_script}{POSTAMBLE}")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def git_blame_raw(project: str, h: str | None, f: str | None) -> JSONResponse:
|
|
846
|
+
"""Per-file blame as JSON: merged contiguous hunks per commit with line ranges."""
|
|
847
|
+
if not f:
|
|
848
|
+
raise HTTPException(status_code=400, detail="File path (f) required")
|
|
849
|
+
hunks = _blame_for_file(project, h, f)
|
|
850
|
+
out = [
|
|
851
|
+
{
|
|
852
|
+
"commit_id": h["commit_id"],
|
|
853
|
+
"commit_msg": h["commit_msg"],
|
|
854
|
+
"commit_author": h["commit_author"],
|
|
855
|
+
"time": h["time"],
|
|
856
|
+
"line_range": (
|
|
857
|
+
str(h["line_start"]) if h["line_start"] == h["line_end"] else f"{h['line_start']}-{h['line_end']}"
|
|
858
|
+
),
|
|
859
|
+
}
|
|
860
|
+
for h in hunks
|
|
861
|
+
]
|
|
862
|
+
return JSONResponse(out)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
|
|
866
|
+
"""Tree page: list files and directories; directories link to tree with f=path."""
|
|
867
|
+
if f is not None and not is_valid_pathname(f):
|
|
868
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
869
|
+
result = get_tree_at_ref_path(project, h, f)
|
|
870
|
+
if not result:
|
|
871
|
+
raise HTTPException(status_code=404, detail="Tree or path not found")
|
|
872
|
+
_tree, ref_oid = result
|
|
873
|
+
base = f"/project/{project}"
|
|
874
|
+
breadcrumbs = [f'<a href="{base}">{jinja_escape(project)}</a>']
|
|
875
|
+
if f:
|
|
876
|
+
parts = f.strip("/").split("/")
|
|
877
|
+
for i, seg in enumerate(parts):
|
|
878
|
+
prefix = "/".join(parts[: i + 1])
|
|
879
|
+
breadcrumbs.append(f' / <a href="{_tree_url(project, ref_oid, prefix)}">{jinja_escape(seg)}</a>')
|
|
880
|
+
breadcrumb_html = "".join(breadcrumbs)
|
|
881
|
+
title_path = f" / {f}" if f else ""
|
|
882
|
+
pre = PREAMBLE.render(
|
|
883
|
+
title=f"{jinja_escape(project)}{jinja_escape(title_path)} - Tree",
|
|
884
|
+
site_name=settings.SITE_NAME,
|
|
885
|
+
)
|
|
886
|
+
table = _render_tree_table(project, h, f)
|
|
887
|
+
body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{jinja_escape(title_path)}</h1>{table}"]
|
|
888
|
+
readme = get_readme_at_ref_path(project, ref_oid, f or "")
|
|
889
|
+
if readme:
|
|
890
|
+
readme_filename, readme_content = readme
|
|
891
|
+
body_parts.append(_render_readme_card(project, ref_oid, readme_filename, readme_content, f or ""))
|
|
892
|
+
body_parts.append(POSTAMBLE)
|
|
893
|
+
return HTMLResponse("".join(body_parts))
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
|
|
897
|
+
"""Commit log page. Port of git_log."""
|
|
898
|
+
skip = (page - 1) * pagecount
|
|
899
|
+
commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
|
|
900
|
+
if not commits:
|
|
901
|
+
raise HTTPException(status_code=404, detail="No commits found")
|
|
902
|
+
has_next = len(commits) > pagecount
|
|
903
|
+
if has_next:
|
|
904
|
+
commits = commits[:pagecount]
|
|
905
|
+
rows = []
|
|
906
|
+
for commit in commits:
|
|
907
|
+
rows.append(_format_commit_table_row(project, commit, short=False))
|
|
908
|
+
ref_display = h[:7] if h else "HEAD"
|
|
909
|
+
title = f"Log - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
|
|
910
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
911
|
+
table = env.get_template("table.html").render(
|
|
912
|
+
cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"], rows=rows
|
|
913
|
+
)
|
|
914
|
+
pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
|
|
915
|
+
return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
|
|
919
|
+
"""Shortlog page. Port of git_shortlog."""
|
|
920
|
+
skip = (page - 1) * pagecount
|
|
921
|
+
commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
|
|
922
|
+
if not commits:
|
|
923
|
+
raise HTTPException(status_code=404, detail="No commits found")
|
|
924
|
+
has_next = len(commits) > pagecount
|
|
925
|
+
if has_next:
|
|
926
|
+
commits = commits[:pagecount]
|
|
927
|
+
rows = []
|
|
928
|
+
for commit in commits:
|
|
929
|
+
rows.append(_format_commit_table_row(project, commit, short=True))
|
|
930
|
+
ref_display = h[:7] if h else "HEAD"
|
|
931
|
+
title = f"Shortlog - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
|
|
932
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
933
|
+
table = env.get_template("table.html").render(cols=["Commit", "Subject", "Author", "Age", "Diff"], rows=rows)
|
|
934
|
+
pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
|
|
935
|
+
return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def git_history(
|
|
939
|
+
project: str,
|
|
940
|
+
h: str | None,
|
|
941
|
+
f: str | None,
|
|
942
|
+
request: Request,
|
|
943
|
+
page: int,
|
|
944
|
+
pagecount: int,
|
|
945
|
+
) -> HTMLResponse:
|
|
946
|
+
"""History page for a file or directory. Port of git_history."""
|
|
947
|
+
if not f:
|
|
948
|
+
raise HTTPException(status_code=400, detail="File path (f) required for history")
|
|
949
|
+
if not is_valid_pathname(f):
|
|
950
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
951
|
+
skip = (page - 1) * pagecount
|
|
952
|
+
commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
|
|
953
|
+
if not commits:
|
|
954
|
+
raise HTTPException(status_code=404, detail="No history found for this path")
|
|
955
|
+
has_next = len(commits) > pagecount
|
|
956
|
+
if has_next:
|
|
957
|
+
commits = commits[:pagecount]
|
|
958
|
+
rows = []
|
|
959
|
+
for commit in commits:
|
|
960
|
+
rows.append(_format_commit_table_row(project, commit, short=False))
|
|
961
|
+
ref_display = h[:7] if h else "HEAD"
|
|
962
|
+
title = f"History - {jinja_escape(f)} - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
|
|
963
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
964
|
+
table = env.get_template("table.html").render(
|
|
965
|
+
cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"], rows=rows
|
|
966
|
+
)
|
|
967
|
+
pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
|
|
968
|
+
return HTMLResponse(
|
|
969
|
+
f"{pre}<h1>{title}</h1>"
|
|
970
|
+
f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
|
|
971
|
+
f"{table}{pagination_html}{POSTAMBLE}"
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def git_heads(project: str) -> HTMLResponse:
|
|
976
|
+
"""Heads (branches) list page. Port of git_heads."""
|
|
977
|
+
heads_list = git_get_heads_list(project)
|
|
978
|
+
if not heads_list:
|
|
979
|
+
raise HTTPException(status_code=404, detail="No heads found")
|
|
980
|
+
rows = []
|
|
981
|
+
for name, _ref, oid in heads_list:
|
|
982
|
+
oid_short = oid[:7] if oid else ""
|
|
983
|
+
commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
|
|
984
|
+
tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
|
|
985
|
+
rows.append([
|
|
986
|
+
f'<a href="{commit_link}">{jinja_escape(name)}</a>',
|
|
987
|
+
f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
|
|
988
|
+
f'<a href="{tree_link}">tree</a>',
|
|
989
|
+
])
|
|
990
|
+
title = f"Heads - {jinja_escape(project)}"
|
|
991
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
992
|
+
table = env.get_template("table.html").render(
|
|
993
|
+
cols=["Head", "Commit", ""],
|
|
994
|
+
rows=rows,
|
|
995
|
+
)
|
|
996
|
+
return HTMLResponse(
|
|
997
|
+
f"{pre}<h1>{title}</h1>"
|
|
998
|
+
+ f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>{table}"
|
|
999
|
+
+ POSTAMBLE
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
|
|
1004
|
+
"""Tags list page. Port of git_tags."""
|
|
1005
|
+
tags_list = git_get_tags_list(project)
|
|
1006
|
+
if not tags_list:
|
|
1007
|
+
raise HTTPException(status_code=404, detail="No tags found")
|
|
1008
|
+
total = len(tags_list)
|
|
1009
|
+
total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
|
|
1010
|
+
if page > total_pages and total > 0:
|
|
1011
|
+
raise HTTPException(status_code=404, detail="Page does not exist")
|
|
1012
|
+
skip = (page - 1) * pagecount
|
|
1013
|
+
tags_page = tags_list[skip : skip + pagecount]
|
|
1014
|
+
try:
|
|
1015
|
+
repo = open_repo(project)
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
raise HTTPException(status_code=404, detail="Repository not found") from e
|
|
1018
|
+
rows = []
|
|
1019
|
+
for name, _ref, oid in tags_page:
|
|
1020
|
+
tag_link = f"/project/{project}?a=tag&h={quote(oid, safe='')}"
|
|
1021
|
+
target_oid = oid
|
|
1022
|
+
target_type = "commit"
|
|
1023
|
+
try:
|
|
1024
|
+
obj = repo.revparse_single(oid)
|
|
1025
|
+
if isinstance(obj, pygit2.Tag):
|
|
1026
|
+
target_oid = str(obj.target)
|
|
1027
|
+
target_type = obj.type_str
|
|
1028
|
+
except (KeyError, pygit2.GitError):
|
|
1029
|
+
pass
|
|
1030
|
+
target_short = target_oid[:7] if target_oid else ""
|
|
1031
|
+
if target_type == "commit":
|
|
1032
|
+
target_link = f"/project/{project}?a=commit&h={quote(target_oid, safe='')}"
|
|
1033
|
+
elif target_type == "tree":
|
|
1034
|
+
target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
|
|
1035
|
+
else:
|
|
1036
|
+
target_link = None
|
|
1037
|
+
obj_cell = (
|
|
1038
|
+
f'<a href="{target_link}">{jinja_escape(target_short)}</a>' if target_link else jinja_escape(target_short)
|
|
1039
|
+
)
|
|
1040
|
+
rows.append([
|
|
1041
|
+
f'<a href="{tag_link}">{jinja_escape(name)}</a>',
|
|
1042
|
+
obj_cell,
|
|
1043
|
+
])
|
|
1044
|
+
title = f"Tags - {jinja_escape(project)}"
|
|
1045
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
1046
|
+
table = env.get_template("table.html").render(
|
|
1047
|
+
cols=["Tag", "Object"],
|
|
1048
|
+
rows=rows,
|
|
1049
|
+
)
|
|
1050
|
+
pagination_html = _render_pagination(
|
|
1051
|
+
request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages
|
|
1052
|
+
)
|
|
1053
|
+
return HTMLResponse(
|
|
1054
|
+
f"{pre}<h1>{title}</h1>"
|
|
1055
|
+
f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
|
|
1056
|
+
f"{table}{pagination_html}{POSTAMBLE}"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def git_tag(project: str, h: str | None) -> HTMLResponse:
|
|
1061
|
+
"""Single tag view. Port of git_tag."""
|
|
1062
|
+
if not h:
|
|
1063
|
+
raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
|
|
1064
|
+
if not is_valid_ref_format(h):
|
|
1065
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
1066
|
+
try:
|
|
1067
|
+
repo = open_repo(project)
|
|
1068
|
+
obj = repo.revparse_single(h)
|
|
1069
|
+
except (KeyError, pygit2.GitError, OSError) as e:
|
|
1070
|
+
raise HTTPException(status_code=404, detail="Tag or object not found") from e
|
|
1071
|
+
if not isinstance(obj, pygit2.Tag):
|
|
1072
|
+
raise HTTPException(status_code=404, detail="Not a tag object")
|
|
1073
|
+
mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
|
|
1074
|
+
if mr_pair is not None:
|
|
1075
|
+
obj, mr = mr_pair
|
|
1076
|
+
else:
|
|
1077
|
+
mr = None
|
|
1078
|
+
tag_oid = str(obj.id)
|
|
1079
|
+
tag_data = parse_tag(project, tag_oid)
|
|
1080
|
+
if not tag_data:
|
|
1081
|
+
raise HTTPException(status_code=404, detail="Tag not found")
|
|
1082
|
+
target_oid = tag_data.get("object", "")
|
|
1083
|
+
target_type = tag_data.get("type", "commit")
|
|
1084
|
+
tagger = tag_data.get("tagger", "")
|
|
1085
|
+
tagger_epoch = tag_data.get("tagger_epoch")
|
|
1086
|
+
tagger_tz = tag_data.get("tagger_tz")
|
|
1087
|
+
message = (tag_data.get("message") or "").strip()
|
|
1088
|
+
tag_name = None
|
|
1089
|
+
for name, _ref, oid in git_get_tags_list(project):
|
|
1090
|
+
if oid == tag_oid:
|
|
1091
|
+
tag_name = name
|
|
1092
|
+
break
|
|
1093
|
+
if tag_name is None:
|
|
1094
|
+
tag_name = tag_oid[:7]
|
|
1095
|
+
if mr is not None:
|
|
1096
|
+
return merge_request_tag_response(
|
|
1097
|
+
project=project,
|
|
1098
|
+
repo=repo,
|
|
1099
|
+
mr=mr,
|
|
1100
|
+
tag_name=tag_name,
|
|
1101
|
+
tag_oid=tag_oid,
|
|
1102
|
+
tagger=tagger,
|
|
1103
|
+
tagger_date=_format_date(tagger_epoch, tagger_tz),
|
|
1104
|
+
)
|
|
1105
|
+
target_short = target_oid[:7] if target_oid else ""
|
|
1106
|
+
if target_type == "commit":
|
|
1107
|
+
object_link = (
|
|
1108
|
+
f'<a href="/project/{project}?a=commit&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
|
|
1109
|
+
)
|
|
1110
|
+
elif target_type == "tree":
|
|
1111
|
+
object_link = (
|
|
1112
|
+
f'<a href="/project/{project}?a=tree&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
|
|
1113
|
+
)
|
|
1114
|
+
else:
|
|
1115
|
+
object_link = jinja_escape(target_short) or ""
|
|
1116
|
+
table_rows = [
|
|
1117
|
+
["Tag", jinja_escape(tag_name)],
|
|
1118
|
+
["Object", f"{object_link} ({jinja_escape(target_type)})"],
|
|
1119
|
+
["Tagger", jinja_escape(tagger)],
|
|
1120
|
+
["Date", jinja_escape(_format_date(tagger_epoch, tagger_tz))],
|
|
1121
|
+
]
|
|
1122
|
+
if message:
|
|
1123
|
+
table_rows.append(["Message", f"<pre class='tag-message'>{jinja_escape(message)}</pre>"])
|
|
1124
|
+
table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
|
|
1125
|
+
title = f"Tag {jinja_escape(tag_name)} - {jinja_escape(project)}"
|
|
1126
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
1127
|
+
return HTMLResponse(
|
|
1128
|
+
f"{pre}<h1>Tag {jinja_escape(tag_name)}</h1>"
|
|
1129
|
+
f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
|
|
1130
|
+
f"{table}{POSTAMBLE}"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def git_commit(project: str, h: str | None) -> HTMLResponse:
|
|
1135
|
+
"""Single commit view. Port of git_commit (git show)."""
|
|
1136
|
+
if not h:
|
|
1137
|
+
raise HTTPException(status_code=400, detail="Commit hash (h) required")
|
|
1138
|
+
if not is_valid_ref_format(h):
|
|
1139
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
1140
|
+
try:
|
|
1141
|
+
repo = open_repo(project)
|
|
1142
|
+
obj = repo.revparse_single(h)
|
|
1143
|
+
except (KeyError, pygit2.GitError, OSError) as e:
|
|
1144
|
+
raise HTTPException(status_code=404, detail="Commit not found") from e
|
|
1145
|
+
if not isinstance(obj, pygit2.Commit):
|
|
1146
|
+
raise HTTPException(status_code=404, detail="Not a commit object")
|
|
1147
|
+
commit = obj
|
|
1148
|
+
oid = str(commit.id)
|
|
1149
|
+
oid_short = oid[:7]
|
|
1150
|
+
data = parse_commit(project, oid)
|
|
1151
|
+
author = data.get("author", "")
|
|
1152
|
+
author_email = data.get("author_email", "")
|
|
1153
|
+
author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
|
|
1154
|
+
tree_oid = data.get("tree", "")
|
|
1155
|
+
message = (data.get("body") or "").strip()
|
|
1156
|
+
commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
|
|
1157
|
+
tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
|
|
1158
|
+
diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
|
|
1159
|
+
patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
|
|
1160
|
+
table_rows = [
|
|
1161
|
+
["commit", f'<a href="{commit_link}">{jinja_escape(oid)}</a>'],
|
|
1162
|
+
["Author", f"{jinja_escape(author)} <{jinja_escape(author_email)}>"],
|
|
1163
|
+
["Date", jinja_escape(author_date)],
|
|
1164
|
+
["tree", f'<a href="{tree_link}">{jinja_escape(tree_oid[:7])}</a>'],
|
|
1165
|
+
]
|
|
1166
|
+
for i, parent_id in enumerate(commit.parent_ids):
|
|
1167
|
+
parent_oid = str(parent_id)
|
|
1168
|
+
parent_short = parent_oid[:7]
|
|
1169
|
+
parent_link = f"/project/{project}?a=commit&h={quote(parent_oid, safe='')}"
|
|
1170
|
+
label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
|
|
1171
|
+
table_rows.append([label, f'<a href="{parent_link}">{jinja_escape(parent_short)}</a>'])
|
|
1172
|
+
if message:
|
|
1173
|
+
table_rows.append(["", f"<pre class='commit-message'>{jinja_escape(message)}</pre>"])
|
|
1174
|
+
table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
|
|
1175
|
+
table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
|
|
1176
|
+
title = f"Commit {jinja_escape(oid_short)} - {jinja_escape(project)}"
|
|
1177
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
1178
|
+
return HTMLResponse(
|
|
1179
|
+
f"{pre}<h1>Commit {jinja_escape(oid_short)}</h1>"
|
|
1180
|
+
f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
|
|
1181
|
+
f"{table}{POSTAMBLE}"
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
|
|
1186
|
+
"""Commit diff page: unified diff rendered with diff2html (script id diff2html-script)."""
|
|
1187
|
+
diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
|
|
1188
|
+
diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
|
|
1189
|
+
title = f"Commit diff {oid_short} - {project}"
|
|
1190
|
+
pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
|
|
1191
|
+
body = env.get_template("commitdiff.html").render(
|
|
1192
|
+
project=project,
|
|
1193
|
+
oid_short=oid_short,
|
|
1194
|
+
diff_b64=diff_b64,
|
|
1195
|
+
commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
|
|
1196
|
+
)
|
|
1197
|
+
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def git_patch(project: str, h: str | None) -> PlainTextResponse:
|
|
1201
|
+
"""Single-commit patch (plain text unified diff)."""
|
|
1202
|
+
if not h:
|
|
1203
|
+
raise HTTPException(status_code=400, detail="Commit hash (h) required")
|
|
1204
|
+
body, oid_short = _commit_unified_diff_or_raise(project, h)
|
|
1205
|
+
filename = f"{project}-{oid_short}.patch"
|
|
1206
|
+
return PlainTextResponse(
|
|
1207
|
+
body,
|
|
1208
|
+
media_type="text/x-diff; charset=utf-8",
|
|
1209
|
+
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
|
|
1214
|
+
"""Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
|
|
1215
|
+
if not h:
|
|
1216
|
+
raise HTTPException(status_code=400, detail="Commit hash (h) required")
|
|
1217
|
+
if hb:
|
|
1218
|
+
oids = get_commits_in_range(project, h, hb)
|
|
1219
|
+
if not oids:
|
|
1220
|
+
raise HTTPException(status_code=404, detail="No commits in range")
|
|
1221
|
+
parts = []
|
|
1222
|
+
for oid in oids:
|
|
1223
|
+
try:
|
|
1224
|
+
diff, _ = get_commit_unified_diff(project, oid)
|
|
1225
|
+
if diff:
|
|
1226
|
+
parts.append(diff)
|
|
1227
|
+
except (KeyError, pygit2.GitError, OSError, ValueError):
|
|
1228
|
+
pass
|
|
1229
|
+
body = "\n".join(parts)
|
|
1230
|
+
filename = f"{project}-{h[:7]}-patches.patch"
|
|
1231
|
+
else:
|
|
1232
|
+
body, oid_short = _commit_unified_diff_or_raise(project, h)
|
|
1233
|
+
filename = f"{project}-{oid_short}.patch"
|
|
1234
|
+
return PlainTextResponse(
|
|
1235
|
+
body,
|
|
1236
|
+
media_type="text/x-diff; charset=utf-8",
|
|
1237
|
+
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def git_blobdiff(
|
|
1242
|
+
project: str,
|
|
1243
|
+
h: str | None,
|
|
1244
|
+
hb: str | None,
|
|
1245
|
+
f: str | None,
|
|
1246
|
+
fp: str | None,
|
|
1247
|
+
) -> HTMLResponse:
|
|
1248
|
+
"""Blob diff page: diff between two blob versions, rendered with diff2html."""
|
|
1249
|
+
if not h or not hb:
|
|
1250
|
+
raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
|
|
1251
|
+
if not f:
|
|
1252
|
+
raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
|
|
1253
|
+
if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
|
|
1254
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
1255
|
+
path_new = f or ""
|
|
1256
|
+
path_old = fp if fp is not None else path_new
|
|
1257
|
+
if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
|
|
1258
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
1259
|
+
diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
|
|
1260
|
+
if diff_text is None:
|
|
1261
|
+
raise HTTPException(status_code=404, detail="Blob not found")
|
|
1262
|
+
diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
|
|
1263
|
+
blob_link = f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
|
|
1264
|
+
pre = PREAMBLE.render(
|
|
1265
|
+
title=f"Blob diff - {jinja_escape(path_new)} - {jinja_escape(project)}",
|
|
1266
|
+
site_name=settings.SITE_NAME,
|
|
1267
|
+
)
|
|
1268
|
+
body = env.get_template("blobdiff.html").render(
|
|
1269
|
+
project=project,
|
|
1270
|
+
path_new=path_new,
|
|
1271
|
+
diff_b64=diff_b64,
|
|
1272
|
+
blob_link=blob_link,
|
|
1273
|
+
)
|
|
1274
|
+
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def git_blobpatch(
|
|
1278
|
+
project: str,
|
|
1279
|
+
h: str | None,
|
|
1280
|
+
hb: str | None,
|
|
1281
|
+
f: str | None,
|
|
1282
|
+
fp: str | None,
|
|
1283
|
+
) -> PlainTextResponse:
|
|
1284
|
+
"""Blob diff as plain unified diff."""
|
|
1285
|
+
if not h or not hb:
|
|
1286
|
+
raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
|
|
1287
|
+
if not f:
|
|
1288
|
+
raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
|
|
1289
|
+
if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
|
|
1290
|
+
raise HTTPException(status_code=400, detail="Invalid ref or hash")
|
|
1291
|
+
path_new = f or ""
|
|
1292
|
+
path_old = fp if fp is not None else path_new
|
|
1293
|
+
if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
|
|
1294
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
1295
|
+
body = get_blob_unified_diff(project, hb, path_old, h, path_new)
|
|
1296
|
+
if body is None:
|
|
1297
|
+
raise HTTPException(status_code=404, detail="Blob not found")
|
|
1298
|
+
filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
|
|
1299
|
+
return PlainTextResponse(
|
|
1300
|
+
body,
|
|
1301
|
+
media_type="text/x-diff; charset=utf-8",
|
|
1302
|
+
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
1303
|
+
)
|