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.
Files changed (95) hide show
  1. pygitweb/__init__.py +22 -0
  2. pygitweb/__meta__.py +9 -0
  3. pygitweb/actions.py +1303 -0
  4. pygitweb/addproject_permissions_test.py +114 -0
  5. pygitweb/api/__init__.py +0 -0
  6. pygitweb/api/action.py +20 -0
  7. pygitweb/api/plugins/__init__.py +0 -0
  8. pygitweb/api/plugins/project_zip.py +71 -0
  9. pygitweb/api/project.py +67 -0
  10. pygitweb/api/subpage.py +22 -0
  11. pygitweb/auth.py +387 -0
  12. pygitweb/auth.schema.json +106 -0
  13. pygitweb/auth_config.py +243 -0
  14. pygitweb/auth_oauth.py +343 -0
  15. pygitweb/auth_test.py +390 -0
  16. pygitweb/boards_permissions_test.py +113 -0
  17. pygitweb/change_queue.py +110 -0
  18. pygitweb/change_queue_routes_test.py +146 -0
  19. pygitweb/change_queue_test.py +91 -0
  20. pygitweb/comments_permissions_test.py +140 -0
  21. pygitweb/config.py +185 -0
  22. pygitweb/conftest.py +43 -0
  23. pygitweb/dependencies.py +87 -0
  24. pygitweb/formatting.py +182 -0
  25. pygitweb/formatting_test.py +68 -0
  26. pygitweb/git_helpers.py +454 -0
  27. pygitweb/git_helpers_test.py +223 -0
  28. pygitweb/gravatar.py +16 -0
  29. pygitweb/gravatar_test.py +10 -0
  30. pygitweb/hooks_install.py +278 -0
  31. pygitweb/hooks_install_test.py +363 -0
  32. pygitweb/hooks_permissions_test.py +116 -0
  33. pygitweb/main.py +887 -0
  34. pygitweb/merge_requests.py +230 -0
  35. pygitweb/merge_requests_test.py +85 -0
  36. pygitweb/mr_permissions_test.py +148 -0
  37. pygitweb/password_hash.py +74 -0
  38. pygitweb/password_hash_test.py +20 -0
  39. pygitweb/permissions.py +209 -0
  40. pygitweb/permissions_test.py +86 -0
  41. pygitweb/plugin_loader.py +83 -0
  42. pygitweb/projects.py +213 -0
  43. pygitweb/projects_test.py +186 -0
  44. pygitweb/sessions.py +73 -0
  45. pygitweb/settings.py +412 -0
  46. pygitweb/settings.schema.json +73 -0
  47. pygitweb/settings_config.py +122 -0
  48. pygitweb/settings_config_test.py +101 -0
  49. pygitweb/settings_permissions_test.py +76 -0
  50. pygitweb/static/blob-view.js +133 -0
  51. pygitweb/static/board.js +839 -0
  52. pygitweb/static/hook-install.js +53 -0
  53. pygitweb/static/icons/cloud-down.svg +1 -0
  54. pygitweb/static/icons/cloud-exclamation.svg +1 -0
  55. pygitweb/static/icons/cloud-filled.svg +1 -0
  56. pygitweb/static/icons/cloud-storm.svg +1 -0
  57. pygitweb/static/icons/cloud-up.svg +1 -0
  58. pygitweb/static/icons/cloud.svg +1 -0
  59. pygitweb/static/logo.svg +44 -0
  60. pygitweb/static/main.css +1210 -0
  61. pygitweb/static/navbar-auth.js +82 -0
  62. pygitweb/static/readme-render.js +174 -0
  63. pygitweb/static/search-page.js +139 -0
  64. pygitweb/static/summary-ref-switcher.js +130 -0
  65. pygitweb/static/themes/solarized.css +34 -0
  66. pygitweb/tasks.py +595 -0
  67. pygitweb/tasks_permissions_test.py +137 -0
  68. pygitweb/tasks_routes_test.py +430 -0
  69. pygitweb/templates/addproject.html +174 -0
  70. pygitweb/templates/blobdiff.html +57 -0
  71. pygitweb/templates/board.html +31 -0
  72. pygitweb/templates/board_card.html +44 -0
  73. pygitweb/templates/commitdiff.html +57 -0
  74. pygitweb/templates/hooks_summary_cell.html +37 -0
  75. pygitweb/templates/login.html +37 -0
  76. pygitweb/templates/merge_request_summary_cell.html +18 -0
  77. pygitweb/templates/merge_request_tag.html +90 -0
  78. pygitweb/templates/pagination.html +44 -0
  79. pygitweb/templates/preamble.html +89 -0
  80. pygitweb/templates/project_settings.html +13 -0
  81. pygitweb/templates/pygit2_settings.html +12 -0
  82. pygitweb/templates/pygitweb_settings.html +12 -0
  83. pygitweb/templates/search.html +77 -0
  84. pygitweb/templates/settings_form_field.html +27 -0
  85. pygitweb/templates/table.html +15 -0
  86. pygitweb/templates/user_profile.html +10 -0
  87. pygitweb/templates_env.py +21 -0
  88. pygitweb/timeline_cache.py +243 -0
  89. pygitweb/validation.py +75 -0
  90. pygitweb/validation_test.py +167 -0
  91. pygitweb-0.1.0.dist-info/METADATA +165 -0
  92. pygitweb-0.1.0.dist-info/RECORD +95 -0
  93. pygitweb-0.1.0.dist-info/WHEEL +5 -0
  94. pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. 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("&", "&amp;").replace('"', "&quot;")
336
+ blob_dir_attr = blob_dir.replace("&", "&amp;").replace('"', "&quot;") 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)} &lt;{jinja_escape(author_email)}&gt;"],
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
+ )