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/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(" ", "&nbsp;")
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">&#9217;</span>'
44
+ assert formatting.quot_upr("\x01", nohtml=True) == "&#9217;"
45
+
46
+
47
+ def test_esc_path_escapes_html_and_control_chars() -> None:
48
+ assert formatting.esc_path("a<\n") == 'a&lt;<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&nbsp;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"
@@ -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