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/formatting.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formatting: escaping (esc_param, esc_path_info, esc_url, esc_path, sanitize),
|
|
3
|
+
quot_cec, quot_upr, unquote, untabify, to_utf8, age_class, age_string.
|
|
4
|
+
Ported from gitweb/gitweb.perl.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import quote, quote_plus
|
|
12
|
+
|
|
13
|
+
# Fallback encoding when bytes are not valid UTF-8 (gitweb: $fallback_encoding)
|
|
14
|
+
FALLBACK_ENCODING = "latin1"
|
|
15
|
+
|
|
16
|
+
# Control character escape codes (CEC). Port of quot_cec.
|
|
17
|
+
_CEC_MAP = {
|
|
18
|
+
"\t": r"\t",
|
|
19
|
+
"\n": r"\n",
|
|
20
|
+
"\r": r"\r",
|
|
21
|
+
"\f": r"\f",
|
|
22
|
+
"\b": r"\b",
|
|
23
|
+
"\a": r"\a",
|
|
24
|
+
"\x1b": r"\e",
|
|
25
|
+
"\v": r"\v",
|
|
26
|
+
"\0": r"\0",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_utf8(s: str | bytes | None) -> str | None:
|
|
31
|
+
"""Decode to UTF-8 string; use fallback encoding if not valid UTF-8. Port of to_utf8."""
|
|
32
|
+
if s is None:
|
|
33
|
+
return None
|
|
34
|
+
if isinstance(s, str):
|
|
35
|
+
return s
|
|
36
|
+
try:
|
|
37
|
+
return s.decode("utf-8")
|
|
38
|
+
except UnicodeDecodeError:
|
|
39
|
+
return s.decode(FALLBACK_ENCODING, errors="replace")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def esc_param(s: str | None) -> str | None:
|
|
43
|
+
"""URL-encode for query param; keep / and space as +. Port of esc_param."""
|
|
44
|
+
if s is None:
|
|
45
|
+
return None
|
|
46
|
+
return quote_plus(s, safe="") # gitweb keeps -_.~()/@: and space→+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def esc_path_info(s: str | None) -> str | None:
|
|
50
|
+
"""Path segment encoding; ? must be escaped. Port of esc_path_info."""
|
|
51
|
+
if s is None:
|
|
52
|
+
return None
|
|
53
|
+
# Safe: A-Za-z0-9\-_.~();/;:@&= +
|
|
54
|
+
return quote(s, safe="-_.~();/:@&= ")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def esc_url(s: str | None) -> str | None:
|
|
58
|
+
"""URL encoding for href. Port of esc_url (same idea as esc_param)."""
|
|
59
|
+
if s is None:
|
|
60
|
+
return None
|
|
61
|
+
return quote(s, safe="-_.~()/:@!")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def quot_cec(char: str, nohtml: bool = False) -> str:
|
|
65
|
+
"""Printable representation of control char (CEC). Port of quot_cec."""
|
|
66
|
+
out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
|
|
67
|
+
if nohtml:
|
|
68
|
+
return out
|
|
69
|
+
return f'<span class="cntrl">{out}</span>'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def quot_upr(char: str, nohtml: bool = False) -> str:
|
|
73
|
+
"""Unicode control pictures. Port of quot_upr."""
|
|
74
|
+
code = 0x2400 + ord(char)
|
|
75
|
+
out = f"&#{code};"
|
|
76
|
+
if nohtml:
|
|
77
|
+
return out
|
|
78
|
+
return f'<span class="cntrl">{out}</span>'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def esc_path(s: str | None, nbsp: bool = False) -> str | None:
|
|
82
|
+
"""UTF-8, HTML-escape, then control chars to quot_cec. Port of esc_path."""
|
|
83
|
+
if s is None:
|
|
84
|
+
return None
|
|
85
|
+
s = to_utf8(s) or s
|
|
86
|
+
s = html.escape(s, quote=False)
|
|
87
|
+
if nbsp:
|
|
88
|
+
s = s.replace(" ", " ")
|
|
89
|
+
result = []
|
|
90
|
+
for c in s:
|
|
91
|
+
if ord(c) < 32 or ord(c) == 127:
|
|
92
|
+
result.append(quot_cec(c))
|
|
93
|
+
else:
|
|
94
|
+
result.append(c)
|
|
95
|
+
return "".join(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sanitize(s: str | None) -> str | None:
|
|
99
|
+
"""XHTML-safe: control chars to CEC except tab/lf/cr. Port of sanitize."""
|
|
100
|
+
if s is None:
|
|
101
|
+
return None
|
|
102
|
+
s = to_utf8(s) or s
|
|
103
|
+
result = []
|
|
104
|
+
for c in s:
|
|
105
|
+
if c in "\t\n\r":
|
|
106
|
+
result.append(c)
|
|
107
|
+
elif ord(c) < 32 or ord(c) == 127:
|
|
108
|
+
result.append(quot_cec(c, nohtml=True))
|
|
109
|
+
else:
|
|
110
|
+
result.append(c)
|
|
111
|
+
return "".join(result)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def unquote(s: str | None) -> str:
|
|
115
|
+
"""Unescape git-style quoted filenames (C and octal). Port of unquote."""
|
|
116
|
+
if s is None:
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
def unq(seq: str) -> str:
|
|
120
|
+
es = {
|
|
121
|
+
"t": "\t",
|
|
122
|
+
"n": "\n",
|
|
123
|
+
"r": "\r",
|
|
124
|
+
"f": "\f",
|
|
125
|
+
"b": "\b",
|
|
126
|
+
"a": "\a",
|
|
127
|
+
"e": "\x1b",
|
|
128
|
+
"v": "\v",
|
|
129
|
+
}
|
|
130
|
+
if re.match(r"^[0-7]{1,3}$", seq):
|
|
131
|
+
return chr(int(seq, 8))
|
|
132
|
+
return es.get(seq, seq)
|
|
133
|
+
|
|
134
|
+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
|
135
|
+
s = s[1:-1]
|
|
136
|
+
s = re.sub(r"\\([^0-7]|[0-7]{1,3})", lambda m: unq(m.group(1)), s)
|
|
137
|
+
return s
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def untabify(line: str, tabwidth: int = 8) -> str:
|
|
141
|
+
"""Expand tabs to spaces. Port of untabify."""
|
|
142
|
+
result = []
|
|
143
|
+
col = 0
|
|
144
|
+
for c in line:
|
|
145
|
+
if c == "\t":
|
|
146
|
+
n = tabwidth - (col % tabwidth)
|
|
147
|
+
result.append(" " * n)
|
|
148
|
+
col += n
|
|
149
|
+
else:
|
|
150
|
+
result.append(c)
|
|
151
|
+
col += 1
|
|
152
|
+
return "".join(result)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def age_class(age_seconds: float | None) -> str:
|
|
156
|
+
"""CSS class for age. Port of age_class."""
|
|
157
|
+
if age_seconds is None:
|
|
158
|
+
return "noage"
|
|
159
|
+
if age_seconds < 2 * 3600:
|
|
160
|
+
return "age0"
|
|
161
|
+
if age_seconds < 2 * 86400:
|
|
162
|
+
return "age1"
|
|
163
|
+
return "age2"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def age_string(age_seconds: float) -> str:
|
|
167
|
+
"""Human-readable age. Port of age_string."""
|
|
168
|
+
if age_seconds > 2 * 365 * 86400:
|
|
169
|
+
return f"{int(age_seconds / 86400 / 365)} years ago"
|
|
170
|
+
if age_seconds > 2 * (365 / 12) * 86400:
|
|
171
|
+
return f"{int(age_seconds / 86400 / (365 / 12))} months ago"
|
|
172
|
+
if age_seconds > 2 * 7 * 86400:
|
|
173
|
+
return f"{int(age_seconds / 86400 / 7)} weeks ago"
|
|
174
|
+
if age_seconds > 2 * 86400:
|
|
175
|
+
return f"{int(age_seconds / 86400)} days ago"
|
|
176
|
+
if age_seconds > 2 * 3600:
|
|
177
|
+
return f"{int(age_seconds / 3600)} hours ago"
|
|
178
|
+
if age_seconds > 2 * 60:
|
|
179
|
+
return f"{int(age_seconds / 60)} min ago"
|
|
180
|
+
if age_seconds > 2:
|
|
181
|
+
return f"{int(age_seconds)} sec ago"
|
|
182
|
+
return "right now"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pygitweb.formatting as formatting
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_to_utf8_returns_string_input_unchanged() -> None:
|
|
5
|
+
assert formatting.to_utf8("hello") == "hello"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_to_utf8_decodes_utf8_bytes_successfully() -> None:
|
|
9
|
+
assert formatting.to_utf8("cafe".encode("utf-8")) == "cafe" # noqa: UP012
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_to_utf8_uses_fallback_encoding_when_utf8_fails() -> None:
|
|
13
|
+
assert formatting.to_utf8(b"\xf1") == "ñ"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_to_utf8_fallback_decode_uses_replace_for_invalid_bytes() -> None:
|
|
17
|
+
original: str = formatting.FALLBACK_ENCODING
|
|
18
|
+
formatting.FALLBACK_ENCODING = "ascii"
|
|
19
|
+
try:
|
|
20
|
+
assert formatting.to_utf8(b"\xffabc") == "\ufffdabc"
|
|
21
|
+
finally:
|
|
22
|
+
formatting.FALLBACK_ENCODING = original
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_esc_param_encodes_space_and_slash() -> None:
|
|
26
|
+
assert formatting.esc_param("a b/c") == "a+b%2Fc"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_esc_path_info_escapes_question_mark() -> None:
|
|
30
|
+
assert formatting.esc_path_info("dir/file?.txt") == "dir/file%3F.txt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_esc_url_keeps_url_safe_characters() -> None:
|
|
34
|
+
assert formatting.esc_url("https://example.com/a b") == "https://example.com/a%20b"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_quot_cec_formats_html_and_plain() -> None:
|
|
38
|
+
assert formatting.quot_cec("\n") == '<span class="cntrl">\\n</span>'
|
|
39
|
+
assert formatting.quot_cec("\n", nohtml=True) == "\\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_quot_upr_formats_html_and_plain() -> None:
|
|
43
|
+
assert formatting.quot_upr("\x01") == '<span class="cntrl">␁</span>'
|
|
44
|
+
assert formatting.quot_upr("\x01", nohtml=True) == "␁"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_esc_path_escapes_html_and_control_chars() -> None:
|
|
48
|
+
assert formatting.esc_path("a<\n") == 'a<<span class="cntrl">\\n</span>'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_esc_path_nbsp_replaces_spaces() -> None:
|
|
52
|
+
assert formatting.esc_path("a b", nbsp=True) == "a b"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_sanitize_preserves_whitespace_controls_and_escapes_others() -> None:
|
|
56
|
+
assert formatting.sanitize("x\t\n\r\x01") == "x\t\n\r\\01"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_unquote_unescapes_quoted_sequences() -> None:
|
|
60
|
+
assert formatting.unquote('"a\\tb\\040c"') == "a\tb c"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unquote_returns_input_when_not_wrapped_in_quotes() -> None:
|
|
64
|
+
assert formatting.unquote("plain\\ntext") == "plain\\ntext"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_untabify_expands_tabs_to_next_tab_stop() -> None:
|
|
68
|
+
assert formatting.untabify("a\tb", tabwidth=4) == "a b"
|
pygitweb/git_helpers.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git helpers via pygit2: open repo, rev_parse, config, refs, ls_tree, cat_file, etc.
|
|
3
|
+
Ported from gitweb/gitweb.perl (git_cmd, git_get_type, git_parse_project_config, config_to_bool,
|
|
4
|
+
config_to_int, git_get_project_config, git_get_file_or_project_config, git_get_project_description,
|
|
5
|
+
git_get_references, git_get_heads_list, git_get_tags_list, git_get_remotes_info, parse_commit,
|
|
6
|
+
parse_tag, etc.).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import pygit2
|
|
17
|
+
|
|
18
|
+
from pygitweb.config import settings
|
|
19
|
+
from pygitweb.formatting import to_utf8
|
|
20
|
+
|
|
21
|
+
# Common README filenames to look for (order matters: prefer README.md)
|
|
22
|
+
README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _repo_path(project: str) -> str:
|
|
26
|
+
return os.path.join(settings.PROJECTROOT, project)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def open_repo(project: str):
|
|
30
|
+
"""Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
|
|
31
|
+
path = _repo_path(project)
|
|
32
|
+
return pygit2.Repository(path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git_get_type(project: str, ref: str) -> str | None:
|
|
36
|
+
"""Object type: commit, tree, blob, tag. Port of git_get_type (pygit2 obj.type)."""
|
|
37
|
+
try:
|
|
38
|
+
repo = open_repo(project)
|
|
39
|
+
obj = repo.revparse_single(ref)
|
|
40
|
+
return obj.type_str if obj else None
|
|
41
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hash_set_multi(d: dict[str, Any], key: str, value: Any) -> None:
|
|
46
|
+
"""Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
|
|
47
|
+
if key not in d:
|
|
48
|
+
d[key] = value
|
|
49
|
+
elif not isinstance(d[key], list):
|
|
50
|
+
d[key] = [d[key], value]
|
|
51
|
+
else:
|
|
52
|
+
d[key].append(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, Any]:
|
|
56
|
+
"""All config key/values; optionally filter by section. Port of git_parse_project_config."""
|
|
57
|
+
try:
|
|
58
|
+
repo = open_repo(project)
|
|
59
|
+
cfg = repo.config
|
|
60
|
+
result: dict[str, Any] = {}
|
|
61
|
+
for entry in cfg:
|
|
62
|
+
key = entry.name
|
|
63
|
+
if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
|
|
64
|
+
continue
|
|
65
|
+
value = entry.value
|
|
66
|
+
hash_set_multi(result, key, value)
|
|
67
|
+
return result
|
|
68
|
+
except (pygit2.GitError, OSError):
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def config_to_bool(val: str | None) -> bool:
|
|
73
|
+
"""Config value to bool: true/yes/1. Port of config_to_bool."""
|
|
74
|
+
if val is None:
|
|
75
|
+
return True
|
|
76
|
+
val = (val or "").strip()
|
|
77
|
+
if re.match(r"^\d+$", val):
|
|
78
|
+
return int(val) != 0
|
|
79
|
+
return val.lower() in ("true", "yes")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def config_to_int(val: str | None) -> int | str:
|
|
83
|
+
"""Config value to int; k/m/g suffix. Port of config_to_int."""
|
|
84
|
+
if val is None:
|
|
85
|
+
return 0
|
|
86
|
+
val = (val or "").strip()
|
|
87
|
+
m = re.match(r"^([0-9]*)([kmg])$", val, re.I)
|
|
88
|
+
if m:
|
|
89
|
+
num, unit = m.group(1), m.group(2).lower()
|
|
90
|
+
mult = {"k": 1024, "m": 1048576, "g": 1073741824}.get(unit, 1)
|
|
91
|
+
return int(num or 0) * mult
|
|
92
|
+
return val
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Per-repo cache for gitweb config (git_parse_project_config result)
|
|
96
|
+
_config_cache: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def git_get_project_config(
|
|
100
|
+
project: str,
|
|
101
|
+
key: str,
|
|
102
|
+
config_type: str | None = None,
|
|
103
|
+
) -> int | str | list[str] | bool | None:
|
|
104
|
+
"""Single config value; gitweb.* section. Port of git_get_project_config."""
|
|
105
|
+
key = key.lower().replace("_", "")
|
|
106
|
+
if key.startswith("gitweb."):
|
|
107
|
+
key = key[7:]
|
|
108
|
+
if re.search(r"\W", key):
|
|
109
|
+
return None
|
|
110
|
+
full_key = f"gitweb.{key}"
|
|
111
|
+
git_dir = _repo_path(project)
|
|
112
|
+
cache_key = git_dir
|
|
113
|
+
if cache_key not in _config_cache or _config_cache[cache_key][0] != os.path.join(git_dir, "config"):
|
|
114
|
+
cfg = git_parse_project_config(project, "gitweb")
|
|
115
|
+
_config_cache[cache_key] = (os.path.join(git_dir, "config"), cfg)
|
|
116
|
+
_, cfg = _config_cache[cache_key]
|
|
117
|
+
raw = cfg.get(full_key)
|
|
118
|
+
if raw is None:
|
|
119
|
+
return None
|
|
120
|
+
if config_type == "bool" or config_type == "--bool":
|
|
121
|
+
return config_to_bool(raw[0] if isinstance(raw, list) else raw)
|
|
122
|
+
if config_type == "int" or config_type == "--int":
|
|
123
|
+
return config_to_int(raw[0] if isinstance(raw, list) else raw)
|
|
124
|
+
if isinstance(raw, list):
|
|
125
|
+
return raw[0] if len(raw) == 1 else raw
|
|
126
|
+
return raw
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_tree_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Tree, str] | None:
|
|
130
|
+
"""
|
|
131
|
+
Resolve the tree at ref (commit or tree) and optional path.
|
|
132
|
+
Returns (tree, ref_oid) for listing, or None if not found.
|
|
133
|
+
ref_oid is the resolved OID to use in URLs (same revision).
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
repo = open_repo(project)
|
|
137
|
+
base_ref = ref or (str(repo.head.target) if repo.head else None)
|
|
138
|
+
if not base_ref:
|
|
139
|
+
return None
|
|
140
|
+
obj = repo.revparse_single(base_ref)
|
|
141
|
+
ref_oid = str(obj.id)
|
|
142
|
+
base_tree = obj.peel(pygit2.Tree)
|
|
143
|
+
if not path or not path.strip("/"):
|
|
144
|
+
return (base_tree, ref_oid)
|
|
145
|
+
path_clean = path.strip("/")
|
|
146
|
+
entry = base_tree / path_clean
|
|
147
|
+
if not entry or entry.type_str != "tree":
|
|
148
|
+
return None
|
|
149
|
+
return (repo[entry.id].peel(pygit2.Tree), ref_oid)
|
|
150
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_blob_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Blob, str] | None:
|
|
155
|
+
"""
|
|
156
|
+
Resolve the blob at ref (commit or tree) and path.
|
|
157
|
+
Returns (blob, ref_oid) or None if not found or not a blob.
|
|
158
|
+
"""
|
|
159
|
+
if not path or not path.strip("/"):
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
repo = open_repo(project)
|
|
163
|
+
base_ref = ref or (str(repo.head.target) if repo.head else None)
|
|
164
|
+
if not base_ref:
|
|
165
|
+
return None
|
|
166
|
+
obj = repo.revparse_single(base_ref)
|
|
167
|
+
ref_oid = str(obj.id)
|
|
168
|
+
base_tree = obj.peel(pygit2.Tree)
|
|
169
|
+
path_clean = path.strip("/")
|
|
170
|
+
entry = base_tree / path_clean
|
|
171
|
+
if not entry or entry.type_str != "blob":
|
|
172
|
+
return None
|
|
173
|
+
return (repo[entry.id].peel(pygit2.Blob), ref_oid)
|
|
174
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def git_get_file_or_project_config(project: str, name: str) -> str | None:
|
|
179
|
+
"""Value from $GIT_DIR/name file or gitweb.name config. Port of git_get_file_or_project_config."""
|
|
180
|
+
path = os.path.join(_repo_path(project), name)
|
|
181
|
+
if os.path.isfile(path):
|
|
182
|
+
try:
|
|
183
|
+
with open(path) as f:
|
|
184
|
+
return f.read().strip()
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
val = git_get_project_config(project, name)
|
|
188
|
+
return val[0] if isinstance(val, list) else (val if isinstance(val, str) else None)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def git_get_project_description(project: str) -> str | None:
|
|
192
|
+
"""Content of description file or config. Port of git_get_project_description."""
|
|
193
|
+
return git_get_file_or_project_config(project, "description")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def git_get_references(project: str, ref_prefix: str = "refs/heads") -> list[tuple[str, str]]:
|
|
197
|
+
"""List (ref_name, oid) for prefix. Port of git_get_references (pygit2 references)."""
|
|
198
|
+
try:
|
|
199
|
+
repo = open_repo(project)
|
|
200
|
+
prefix = ref_prefix + "/"
|
|
201
|
+
return [
|
|
202
|
+
(ref_name, str(repo.references[ref_name].resolve().target))
|
|
203
|
+
for ref_name in repo.references
|
|
204
|
+
if ref_name.startswith(prefix)
|
|
205
|
+
]
|
|
206
|
+
except (pygit2.GitError, OSError):
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def git_get_heads_list(project: str) -> list[tuple[str, str, str]]:
|
|
211
|
+
"""List (name, ref, oid) for heads. Port of git_get_heads_list."""
|
|
212
|
+
refs = git_get_references(project, "refs/heads")
|
|
213
|
+
return [(ref.replace("refs/heads/", ""), ref, oid) for ref, oid in refs]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _tag_timestamp(repo: pygit2.Repository, oid: str) -> int:
|
|
217
|
+
"""Return tagger time for an annotated tag, or committer time for a lightweight tag (commit)."""
|
|
218
|
+
try:
|
|
219
|
+
obj = repo.revparse_single(oid)
|
|
220
|
+
if isinstance(obj, pygit2.Tag) and obj.tagger:
|
|
221
|
+
return obj.tagger.time
|
|
222
|
+
if isinstance(obj, pygit2.Commit):
|
|
223
|
+
return obj.committer.time
|
|
224
|
+
except (KeyError, pygit2.GitError):
|
|
225
|
+
pass
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
|
|
230
|
+
"""List (name, ref, oid) for tags. Sorted by tag creation time descending (newest first)."""
|
|
231
|
+
try:
|
|
232
|
+
repo = open_repo(project)
|
|
233
|
+
result = []
|
|
234
|
+
for ref in repo.references.iterator(pygit2.GIT_REFERENCES_TAGS):
|
|
235
|
+
oid = str(ref.resolve().target)
|
|
236
|
+
name = ref.name.replace("refs/tags/", "")
|
|
237
|
+
ts = _tag_timestamp(repo, oid)
|
|
238
|
+
result.append((name, ref.name, oid, ts))
|
|
239
|
+
result.sort(key=lambda x: (x[3], x[0]), reverse=True)
|
|
240
|
+
return [(name, ref_name, oid) for name, ref_name, oid, _ in result]
|
|
241
|
+
except (pygit2.GitError, OSError):
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def git_get_remotes_info(project: str) -> list[dict[str, Any]]:
|
|
246
|
+
"""Remote info: name, url, push_url. Uses pygit2 Remote.url and Remote.push_url."""
|
|
247
|
+
try:
|
|
248
|
+
repo = open_repo(project)
|
|
249
|
+
result = []
|
|
250
|
+
for name in repo.remotes.names():
|
|
251
|
+
remote = repo.remotes[name]
|
|
252
|
+
result.append({
|
|
253
|
+
"name": name,
|
|
254
|
+
"url": remote.url or "",
|
|
255
|
+
"push_url": remote.push_url or remote.url or "",
|
|
256
|
+
})
|
|
257
|
+
return result
|
|
258
|
+
except (pygit2.GitError, OSError):
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_commit(project: str, oid: str) -> dict[str, Any]:
|
|
263
|
+
"""Commit metadata dict. Port of parse_commit (pygit2 commit)."""
|
|
264
|
+
try:
|
|
265
|
+
repo = open_repo(project)
|
|
266
|
+
obj = repo.revparse_single(oid)
|
|
267
|
+
if not isinstance(obj, pygit2.Commit):
|
|
268
|
+
return {}
|
|
269
|
+
commit = obj
|
|
270
|
+
return {
|
|
271
|
+
"parent": [str(p) for p in commit.parent_ids],
|
|
272
|
+
"tree": str(commit.tree_id),
|
|
273
|
+
"author": commit.author.name,
|
|
274
|
+
"author_email": commit.author.email,
|
|
275
|
+
"author_epoch": commit.author.time,
|
|
276
|
+
"author_tz": commit.author.offset,
|
|
277
|
+
"committer": commit.committer.name,
|
|
278
|
+
"committer_email": commit.committer.email,
|
|
279
|
+
"committer_epoch": commit.committer.time,
|
|
280
|
+
"committer_tz": commit.committer.offset,
|
|
281
|
+
"subject": commit.message.split("\n")[0] if commit.message else "",
|
|
282
|
+
"body": commit.message or "",
|
|
283
|
+
}
|
|
284
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
285
|
+
return {}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_tag(project: str, oid: str) -> dict[str, Any]:
|
|
289
|
+
"""Tag metadata. Port of parse_tag (pygit2 tag)."""
|
|
290
|
+
try:
|
|
291
|
+
repo = open_repo(project)
|
|
292
|
+
obj = repo.revparse_single(oid)
|
|
293
|
+
if not isinstance(obj, pygit2.Tag):
|
|
294
|
+
return {}
|
|
295
|
+
tag = obj
|
|
296
|
+
return {
|
|
297
|
+
"object": str(tag.target),
|
|
298
|
+
"type": tag.type_str,
|
|
299
|
+
"tagger": tag.tagger.name if tag.tagger else "",
|
|
300
|
+
"tagger_email": tag.tagger.email if tag.tagger else "",
|
|
301
|
+
"tagger_epoch": tag.tagger.time if tag.tagger else 0,
|
|
302
|
+
"tagger_tz": tag.tagger.offset if tag.tagger else 0,
|
|
303
|
+
"message": tag.message or "",
|
|
304
|
+
}
|
|
305
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
306
|
+
return {}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_commit_history(
|
|
310
|
+
project: str,
|
|
311
|
+
ref: str | None = None,
|
|
312
|
+
path: str | None = None,
|
|
313
|
+
max_count: int = 100,
|
|
314
|
+
skip: int = 0,
|
|
315
|
+
) -> list[dict[str, Any]]:
|
|
316
|
+
"""
|
|
317
|
+
Get commit history for a project, optionally filtered by path.
|
|
318
|
+
Returns list of commit dicts with oid and parsed commit data.
|
|
319
|
+
Port of git log functionality.
|
|
320
|
+
skip: number of matching commits to skip (for pagination).
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
repo = open_repo(project)
|
|
324
|
+
if ref:
|
|
325
|
+
try:
|
|
326
|
+
start_oid = repo.revparse_single(ref).peel(pygit2.Commit).id
|
|
327
|
+
except (KeyError, pygit2.GitError, ValueError):
|
|
328
|
+
start_oid = None
|
|
329
|
+
else:
|
|
330
|
+
start_oid = repo.head.target if repo.head else None
|
|
331
|
+
if not start_oid:
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
commits: list[dict[str, Any]] = []
|
|
335
|
+
skipped = 0
|
|
336
|
+
walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
|
|
337
|
+
|
|
338
|
+
if path:
|
|
339
|
+
# Filter by path: only commits that touched this path (or a file under it)
|
|
340
|
+
path_clean = path.strip("/")
|
|
341
|
+
path_prefix = path_clean + "/"
|
|
342
|
+
|
|
343
|
+
def touched_path(diff: pygit2.Diff) -> bool:
|
|
344
|
+
for delta in diff.deltas:
|
|
345
|
+
old_p, new_p = delta.old_file.path, delta.new_file.path
|
|
346
|
+
if old_p == path_clean or new_p == path_clean:
|
|
347
|
+
return True
|
|
348
|
+
if old_p.startswith(path_prefix) or new_p.startswith(path_prefix):
|
|
349
|
+
return True
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
for commit in walker:
|
|
353
|
+
if len(commits) >= max_count:
|
|
354
|
+
break
|
|
355
|
+
try:
|
|
356
|
+
if commit.parents:
|
|
357
|
+
diff = repo.diff(commit.parents[0], commit)
|
|
358
|
+
if not touched_path(diff):
|
|
359
|
+
continue
|
|
360
|
+
else:
|
|
361
|
+
# Root commit: include if path is root or path exists in tree
|
|
362
|
+
if path_clean:
|
|
363
|
+
try:
|
|
364
|
+
commit.tree / path_clean
|
|
365
|
+
except KeyError:
|
|
366
|
+
continue
|
|
367
|
+
if skipped < skip:
|
|
368
|
+
skipped += 1
|
|
369
|
+
continue
|
|
370
|
+
commit_data = parse_commit(project, str(commit.id))
|
|
371
|
+
commit_data["oid"] = str(commit.id)
|
|
372
|
+
commits.append(commit_data)
|
|
373
|
+
except (KeyError, AttributeError, pygit2.GitError):
|
|
374
|
+
pass
|
|
375
|
+
else:
|
|
376
|
+
# No path filter, get all commits
|
|
377
|
+
for commit in walker:
|
|
378
|
+
if skipped < skip:
|
|
379
|
+
skipped += 1
|
|
380
|
+
continue
|
|
381
|
+
if len(commits) >= max_count:
|
|
382
|
+
break
|
|
383
|
+
commit_data = parse_commit(project, str(commit.id))
|
|
384
|
+
commit_data["oid"] = str(commit.id)
|
|
385
|
+
commits.append(commit_data)
|
|
386
|
+
|
|
387
|
+
return commits
|
|
388
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
|
|
393
|
+
"""Return list of commit OIDs from tip back to (but not including) base. Newest first."""
|
|
394
|
+
try:
|
|
395
|
+
repo = open_repo(project)
|
|
396
|
+
tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
|
|
397
|
+
walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
|
|
398
|
+
if base:
|
|
399
|
+
with suppress(KeyError, pygit2.GitError, ValueError):
|
|
400
|
+
walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
|
|
401
|
+
return [str(c.id) for c in walker]
|
|
402
|
+
except (KeyError, pygit2.GitError, OSError, ValueError):
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def get_blob_unified_diff(
|
|
407
|
+
project: str,
|
|
408
|
+
ref_old: str,
|
|
409
|
+
path_old: str,
|
|
410
|
+
ref_new: str,
|
|
411
|
+
path_new: str,
|
|
412
|
+
) -> str | None:
|
|
413
|
+
"""
|
|
414
|
+
Return unified diff between blob at ref_old:path_old and ref_new:path_new using pygit2.
|
|
415
|
+
Returns None if either blob is not found; otherwise returns the diff string (possibly empty).
|
|
416
|
+
"""
|
|
417
|
+
result_old = get_blob_at_ref_path(project, ref_old, path_old)
|
|
418
|
+
result_new = get_blob_at_ref_path(project, ref_new, path_new)
|
|
419
|
+
if not result_old or not result_new:
|
|
420
|
+
return None
|
|
421
|
+
old_blob, _ = result_old
|
|
422
|
+
new_blob, _ = result_new
|
|
423
|
+
patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
|
|
424
|
+
return patch.text if patch.text else ""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_readme_at_ref_path(project: str, ref: str | None, dir_path: str | None) -> tuple[str, str] | None:
|
|
428
|
+
"""
|
|
429
|
+
If a README exists at ref in the given tree (dir_path), return (filename, utf8_content).
|
|
430
|
+
Otherwise None. dir_path is the tree path (e.g. '' for root, 'docs' for docs/).
|
|
431
|
+
"""
|
|
432
|
+
if not ref:
|
|
433
|
+
return None
|
|
434
|
+
for name in README_CANDIDATES:
|
|
435
|
+
path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
|
|
436
|
+
result = get_blob_at_ref_path(project, ref, path)
|
|
437
|
+
if result:
|
|
438
|
+
blob, _ = result
|
|
439
|
+
text = to_utf8(blob.data) or ""
|
|
440
|
+
return (name, text)
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def get_commit_unified_diff(project: str, h: str) -> tuple[str, str]:
|
|
445
|
+
"""
|
|
446
|
+
Return (unified_diff_text, oid_short) for commit h.
|
|
447
|
+
Raises KeyError, pygit2.GitError, OSError on error (caller should validate ref and map to HTTPException).
|
|
448
|
+
"""
|
|
449
|
+
repo = open_repo(project)
|
|
450
|
+
commit = repo.revparse_single(h).peel(pygit2.Commit)
|
|
451
|
+
diff = repo.diff(commit.parents[0], commit) if commit.parents else commit.tree.diff_to_tree(swap=True)
|
|
452
|
+
body = diff.patch or ""
|
|
453
|
+
oid_short = commit.short_id[:7] if len(commit.short_id) >= 7 else commit.short_id
|
|
454
|
+
return body, oid_short
|