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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pygit2
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pygitweb.config import settings
|
|
12
|
+
from pygitweb.git_helpers import (
|
|
13
|
+
_config_cache,
|
|
14
|
+
config_to_bool,
|
|
15
|
+
config_to_int,
|
|
16
|
+
get_blob_at_ref_path,
|
|
17
|
+
get_blob_unified_diff,
|
|
18
|
+
get_commit_history,
|
|
19
|
+
get_commit_unified_diff,
|
|
20
|
+
get_commits_in_range,
|
|
21
|
+
get_readme_at_ref_path,
|
|
22
|
+
get_tree_at_ref_path,
|
|
23
|
+
git_get_file_or_project_config,
|
|
24
|
+
git_get_heads_list,
|
|
25
|
+
git_get_project_config,
|
|
26
|
+
git_get_project_description,
|
|
27
|
+
git_get_references,
|
|
28
|
+
git_get_remotes_info,
|
|
29
|
+
git_get_tags_list,
|
|
30
|
+
git_get_type,
|
|
31
|
+
git_parse_project_config,
|
|
32
|
+
hash_set_multi,
|
|
33
|
+
parse_commit,
|
|
34
|
+
parse_tag,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_git(repo_dir: Path, *args: str) -> str:
|
|
39
|
+
completed: subprocess.CompletedProcess[str] = subprocess.run(
|
|
40
|
+
["git", *args],
|
|
41
|
+
cwd=repo_dir,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=True,
|
|
45
|
+
)
|
|
46
|
+
return completed.stdout.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestGitHelpers:
|
|
50
|
+
class TestPure:
|
|
51
|
+
def test_hash_set_multi_promotes_to_list(self) -> None:
|
|
52
|
+
data: dict[str, object] = {}
|
|
53
|
+
hash_set_multi(data, "k", "v1")
|
|
54
|
+
hash_set_multi(data, "k", "v2")
|
|
55
|
+
assert data["k"] == ["v1", "v2"]
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize(
|
|
58
|
+
("value", "expected"),
|
|
59
|
+
[("true", True), ("0", False), (None, True)],
|
|
60
|
+
)
|
|
61
|
+
def test_config_to_bool(self, value: str | None, expected: bool) -> None:
|
|
62
|
+
assert config_to_bool(value) is expected
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize(
|
|
65
|
+
("value", "expected"),
|
|
66
|
+
[("2k", 2048), ("3M", 3145728), ("123", "123"), (None, 0)],
|
|
67
|
+
)
|
|
68
|
+
def test_config_to_int(self, value: str | None, expected: int | str) -> None:
|
|
69
|
+
assert config_to_int(value) == expected
|
|
70
|
+
|
|
71
|
+
class TestRepoReadonly:
|
|
72
|
+
@pytest.fixture(scope="class")
|
|
73
|
+
def repo_ctx(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
|
|
74
|
+
project_root: Path = tmp_path_factory.mktemp("git-helpers")
|
|
75
|
+
repo_dir: Path = project_root / "sample"
|
|
76
|
+
repo_dir.mkdir()
|
|
77
|
+
|
|
78
|
+
_run_git(repo_dir, "init", "-b", "main")
|
|
79
|
+
_run_git(repo_dir, "config", "user.name", "Alice")
|
|
80
|
+
_run_git(repo_dir, "config", "user.email", "alice@example.com")
|
|
81
|
+
_run_git(repo_dir, "remote", "add", "origin", "https://example.com/repo.git")
|
|
82
|
+
_run_git(repo_dir, "remote", "set-url", "--push", "origin", "ssh://example.com/repo.git")
|
|
83
|
+
_run_git(repo_dir, "config", "gitweb.owner", "alice")
|
|
84
|
+
_run_git(repo_dir, "config", "--add", "gitweb.category", "infra")
|
|
85
|
+
_run_git(repo_dir, "config", "--add", "gitweb.category", "tools")
|
|
86
|
+
|
|
87
|
+
(repo_dir / "README.md").write_text("Root README\n", encoding="utf-8")
|
|
88
|
+
(repo_dir / "docs").mkdir()
|
|
89
|
+
(repo_dir / "docs" / "README").write_text("Docs README\n", encoding="utf-8")
|
|
90
|
+
(repo_dir / "docs" / "guide.txt").write_text("hello\n", encoding="utf-8")
|
|
91
|
+
(repo_dir / "src").mkdir()
|
|
92
|
+
(repo_dir / "src" / "app.py").write_text("print('v1')\n", encoding="utf-8")
|
|
93
|
+
_run_git(repo_dir, "add", ".")
|
|
94
|
+
_run_git(repo_dir, "commit", "-m", "initial commit")
|
|
95
|
+
|
|
96
|
+
first_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
|
|
97
|
+
(repo_dir / "docs" / "guide.txt").write_text("hello\nworld\n", encoding="utf-8")
|
|
98
|
+
(repo_dir / "src" / "app.py").write_text("print('v2')\n", encoding="utf-8")
|
|
99
|
+
_run_git(repo_dir, "add", ".")
|
|
100
|
+
_run_git(repo_dir, "commit", "-m", "update docs and app")
|
|
101
|
+
second_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
|
|
102
|
+
|
|
103
|
+
_run_git(
|
|
104
|
+
repo_dir,
|
|
105
|
+
"tag",
|
|
106
|
+
"-a",
|
|
107
|
+
"v1.0",
|
|
108
|
+
"-m",
|
|
109
|
+
"release v1.0",
|
|
110
|
+
second_commit,
|
|
111
|
+
)
|
|
112
|
+
_run_git(repo_dir, "tag", "lightweight", first_commit)
|
|
113
|
+
_run_git(repo_dir, "branch", "feature/readme", second_commit)
|
|
114
|
+
|
|
115
|
+
git_dir: Path = repo_dir / ".git"
|
|
116
|
+
(git_dir / "description").write_text("Repo description\n", encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
ctx: dict[str, str] = {
|
|
119
|
+
"project": "sample/.git",
|
|
120
|
+
"first_commit": first_commit,
|
|
121
|
+
"second_commit": second_commit,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_config_cache.clear()
|
|
125
|
+
with patch.object(settings, "PROJECTROOT", str(project_root)):
|
|
126
|
+
yield ctx
|
|
127
|
+
_config_cache.clear()
|
|
128
|
+
|
|
129
|
+
def test_git_get_type(self, repo_ctx: dict[str, str]) -> None:
|
|
130
|
+
assert git_get_type(repo_ctx["project"], "HEAD") == "commit"
|
|
131
|
+
assert git_get_type(repo_ctx["project"], "HEAD^{tree}") == "tree"
|
|
132
|
+
|
|
133
|
+
def test_git_parse_and_get_project_config(self, repo_ctx: dict[str, str]) -> None:
|
|
134
|
+
cfg: dict[str, object] = git_parse_project_config(repo_ctx["project"], "gitweb")
|
|
135
|
+
assert cfg["gitweb.owner"] == "alice"
|
|
136
|
+
assert cfg["gitweb.category"] == ["infra", "tools"]
|
|
137
|
+
assert git_get_project_config(repo_ctx["project"], "owner") == "alice"
|
|
138
|
+
assert git_get_project_config(repo_ctx["project"], "category") == ["infra", "tools"]
|
|
139
|
+
|
|
140
|
+
def test_git_get_file_or_project_config_prefers_file(self, repo_ctx: dict[str, str]) -> None:
|
|
141
|
+
assert git_get_file_or_project_config(repo_ctx["project"], "description") == "Repo description"
|
|
142
|
+
assert git_get_project_description(repo_ctx["project"]) == "Repo description"
|
|
143
|
+
|
|
144
|
+
def test_references_heads_tags_and_remotes(self, repo_ctx: dict[str, str]) -> None:
|
|
145
|
+
heads: list[tuple[str, str, str]] = git_get_heads_list(repo_ctx["project"])
|
|
146
|
+
head_names: set[str] = {name for name, _ref, _oid in heads}
|
|
147
|
+
assert {"main", "feature/readme"} <= head_names
|
|
148
|
+
|
|
149
|
+
refs: list[tuple[str, str]] = git_get_references(repo_ctx["project"])
|
|
150
|
+
assert any(name == "refs/heads/main" for name, _oid in refs)
|
|
151
|
+
|
|
152
|
+
tags: list[tuple[str, str, str]] = git_get_tags_list(repo_ctx["project"])
|
|
153
|
+
tag_names: set[str] = {name for name, _ref, _oid in tags}
|
|
154
|
+
assert {"v1.0", "lightweight"} <= tag_names
|
|
155
|
+
|
|
156
|
+
remotes: list[dict[str, object]] = git_get_remotes_info(repo_ctx["project"])
|
|
157
|
+
assert remotes == [
|
|
158
|
+
{
|
|
159
|
+
"name": "origin",
|
|
160
|
+
"url": "https://example.com/repo.git",
|
|
161
|
+
"push_url": "ssh://example.com/repo.git",
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def test_parse_commit_and_parse_tag(self, repo_ctx: dict[str, str]) -> None:
|
|
166
|
+
commit_data: dict[str, object] = parse_commit(repo_ctx["project"], repo_ctx["second_commit"])
|
|
167
|
+
assert commit_data["subject"] == "update docs and app"
|
|
168
|
+
assert commit_data["author"] == "Alice"
|
|
169
|
+
assert isinstance(commit_data["parent"], list)
|
|
170
|
+
|
|
171
|
+
repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / repo_ctx["project"]))
|
|
172
|
+
tag_oid: str = str(repo.references["refs/tags/v1.0"].target)
|
|
173
|
+
tag_data: dict[str, object] = parse_tag(repo_ctx["project"], tag_oid)
|
|
174
|
+
assert tag_data["type"] == "tag"
|
|
175
|
+
assert tag_data["message"] == "release v1.0\n"
|
|
176
|
+
|
|
177
|
+
def test_tree_blob_and_readme_resolution(self, repo_ctx: dict[str, str]) -> None:
|
|
178
|
+
tree_result = get_tree_at_ref_path(repo_ctx["project"], "HEAD", "docs")
|
|
179
|
+
assert tree_result is not None
|
|
180
|
+
tree, ref_oid = tree_result
|
|
181
|
+
assert ref_oid == repo_ctx["second_commit"]
|
|
182
|
+
assert tree["guide.txt"].type_str == "blob"
|
|
183
|
+
|
|
184
|
+
blob_result = get_blob_at_ref_path(repo_ctx["project"], "HEAD", "docs/guide.txt")
|
|
185
|
+
assert blob_result is not None
|
|
186
|
+
blob, _resolved_ref = blob_result
|
|
187
|
+
assert blob.data.decode("utf-8") == "hello\nworld\n"
|
|
188
|
+
|
|
189
|
+
readme_root = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "")
|
|
190
|
+
assert readme_root == ("README.md", "Root README\n")
|
|
191
|
+
readme_docs = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "docs")
|
|
192
|
+
assert readme_docs == ("README", "Docs README\n")
|
|
193
|
+
|
|
194
|
+
def test_commit_history_and_ranges(self, repo_ctx: dict[str, str]) -> None:
|
|
195
|
+
all_history: list[dict[str, object]] = get_commit_history(repo_ctx["project"], max_count=10)
|
|
196
|
+
assert [entry["oid"] for entry in all_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
|
|
197
|
+
|
|
198
|
+
path_history: list[dict[str, object]] = get_commit_history(
|
|
199
|
+
repo_ctx["project"],
|
|
200
|
+
path="docs/guide.txt",
|
|
201
|
+
max_count=10,
|
|
202
|
+
)
|
|
203
|
+
assert [entry["oid"] for entry in path_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
|
|
204
|
+
|
|
205
|
+
in_range: list[str] = get_commits_in_range(repo_ctx["project"], "HEAD", repo_ctx["first_commit"])
|
|
206
|
+
assert in_range == [repo_ctx["second_commit"]]
|
|
207
|
+
|
|
208
|
+
def test_blob_and_commit_unified_diff(self, repo_ctx: dict[str, str]) -> None:
|
|
209
|
+
blob_diff = get_blob_unified_diff(
|
|
210
|
+
repo_ctx["project"],
|
|
211
|
+
repo_ctx["first_commit"],
|
|
212
|
+
"src/app.py",
|
|
213
|
+
repo_ctx["second_commit"],
|
|
214
|
+
"src/app.py",
|
|
215
|
+
)
|
|
216
|
+
assert blob_diff is not None
|
|
217
|
+
assert "-print('v1')" in blob_diff
|
|
218
|
+
assert "+print('v2')" in blob_diff
|
|
219
|
+
|
|
220
|
+
commit_diff, oid_short = get_commit_unified_diff(repo_ctx["project"], "HEAD")
|
|
221
|
+
assert "docs/guide.txt" in commit_diff
|
|
222
|
+
assert "src/app.py" in commit_diff
|
|
223
|
+
assert oid_short == repo_ctx["second_commit"][:7]
|
pygitweb/gravatar.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Gravatar avatar URLs (https://gravatar.com)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
_GRAVATAR_BASE = "https://www.gravatar.com/avatar"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def gravatar_url(email: str, *, size: int = 64) -> str:
|
|
12
|
+
"""Return a Gravatar image URL for ``email`` (``d=404`` when no profile exists)."""
|
|
13
|
+
normalized = email.strip().lower().encode("utf-8")
|
|
14
|
+
digest = hashlib.md5(normalized).hexdigest() # noqa: S324
|
|
15
|
+
params = urlencode({"s": str(size), "d": "404"})
|
|
16
|
+
return f"{_GRAVATAR_BASE}/{digest}?{params}"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pygitweb.gravatar import gravatar_url
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gravatar_url_known_hash() -> None:
|
|
5
|
+
url = gravatar_url("test@example.com", size=80)
|
|
6
|
+
assert url == "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=80&d=404"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_gravatar_url_normalizes_email() -> None:
|
|
10
|
+
assert gravatar_url(" Test@Example.COM ") == gravatar_url("test@example.com")
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Install, remove, and inspect pygittools hook samples in a project's hooks directory.
|
|
3
|
+
|
|
4
|
+
A "sample" is a file in `pygittools/hook_samples/` whose name encodes the target git
|
|
5
|
+
hook as the prefix before the first dot (e.g. `post-receive.notify` installs as
|
|
6
|
+
`<hooks_dir>/post-receive`). The hooks directory is whichever path pygit2 reports for
|
|
7
|
+
the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TypedDict
|
|
19
|
+
|
|
20
|
+
import pygit2
|
|
21
|
+
|
|
22
|
+
import pygittools
|
|
23
|
+
from pygitweb.config import settings
|
|
24
|
+
|
|
25
|
+
SAMPLES_DIR: Path = Path(pygittools.__file__).parent / "hook_samples"
|
|
26
|
+
|
|
27
|
+
_VERSION_RE: re.Pattern[str] = re.compile(r"""^__VERSION__\s*=\s*(['"])(.*?)\1""", re.MULTILINE)
|
|
28
|
+
|
|
29
|
+
_SAMPLE_LABELS: dict[str, str] = {
|
|
30
|
+
"post-commit.notify": "Post-commit Notify",
|
|
31
|
+
"post-receive.notify": "Post-receive Notify",
|
|
32
|
+
"pre-commit.ruff": "Ruff Pre-commit",
|
|
33
|
+
"commit-msg.pattern": "Commit Message Pattern",
|
|
34
|
+
"pre-receive.protected-pattern": "Protected Branch Commit Messages",
|
|
35
|
+
"post-commit.push-remotes": "Post-commit Push Remotes",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
HOOK_BUNDLES: dict[str, list[str]] = {
|
|
40
|
+
# "Update Hook" wires both server (post-receive on push) and client (post-commit on local
|
|
41
|
+
# commit) sides into the long-poll change queue, so subscribers wake regardless of where
|
|
42
|
+
# the commit was made.
|
|
43
|
+
"update": ["post-commit.notify", "post-receive.notify"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_BUNDLE_LABELS: dict[str, str] = {
|
|
47
|
+
"update": "Update Hook",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HookStatus(StrEnum):
|
|
52
|
+
INSTALLED = "installed"
|
|
53
|
+
NOT_INSTALLED = "not_installed"
|
|
54
|
+
DIFFERENT = "different"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HookSample(TypedDict):
|
|
58
|
+
name: str
|
|
59
|
+
target: str
|
|
60
|
+
source: str
|
|
61
|
+
label: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HookBundle(TypedDict):
|
|
65
|
+
name: str
|
|
66
|
+
label: str
|
|
67
|
+
members: list[HookSample]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InstalledHookInfo(TypedDict):
|
|
71
|
+
name: str
|
|
72
|
+
label: str
|
|
73
|
+
target: str
|
|
74
|
+
status: str
|
|
75
|
+
version: str | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_hook_version(path: Path) -> str | None:
|
|
79
|
+
"""Return the __VERSION__ string from a hook file, if present."""
|
|
80
|
+
try:
|
|
81
|
+
text: str = path.read_text(encoding="utf-8")
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
match: re.Match[str] | None = _VERSION_RE.search(text)
|
|
85
|
+
if match is None:
|
|
86
|
+
return None
|
|
87
|
+
return match.group(2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_installed_version(project: str, sample_name: str) -> str | None:
|
|
91
|
+
sample: HookSample | None = get_sample(sample_name)
|
|
92
|
+
if sample is None:
|
|
93
|
+
raise KeyError(f"Unknown hook sample: {sample_name}")
|
|
94
|
+
target: Path = _target_path(project, sample)
|
|
95
|
+
if target.is_file():
|
|
96
|
+
version: str | None = read_hook_version(target)
|
|
97
|
+
if version is not None:
|
|
98
|
+
return version
|
|
99
|
+
return read_hook_version(Path(sample["source"]))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
|
|
103
|
+
"""Return pygittools hook samples that are installed or conflict with a custom hook."""
|
|
104
|
+
installed: list[InstalledHookInfo] = []
|
|
105
|
+
for sample in list_samples():
|
|
106
|
+
st: HookStatus = status(project, sample["name"])
|
|
107
|
+
if st == HookStatus.NOT_INSTALLED:
|
|
108
|
+
continue
|
|
109
|
+
version: str | None = None
|
|
110
|
+
if st == HookStatus.INSTALLED:
|
|
111
|
+
version = get_installed_version(project, sample["name"])
|
|
112
|
+
else:
|
|
113
|
+
version = read_hook_version(_target_path(project, sample))
|
|
114
|
+
installed.append({
|
|
115
|
+
"name": sample["name"],
|
|
116
|
+
"label": sample["label"],
|
|
117
|
+
"target": sample["target"],
|
|
118
|
+
"status": st.value,
|
|
119
|
+
"version": version,
|
|
120
|
+
})
|
|
121
|
+
return installed
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def list_installable_hooks(project: str) -> list[HookSample]:
|
|
125
|
+
"""Return pygittools hook samples that can still be installed in this project."""
|
|
126
|
+
installable: list[HookSample] = []
|
|
127
|
+
for sample in list_samples():
|
|
128
|
+
if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
|
|
129
|
+
installable.append(sample)
|
|
130
|
+
return installable
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_samples() -> list[HookSample]:
|
|
134
|
+
if not SAMPLES_DIR.is_dir():
|
|
135
|
+
return []
|
|
136
|
+
samples: list[HookSample] = []
|
|
137
|
+
for entry in sorted(SAMPLES_DIR.iterdir()):
|
|
138
|
+
if not entry.is_file() or "." not in entry.name or entry.name.lower().endswith(".md"):
|
|
139
|
+
continue
|
|
140
|
+
target: str = entry.name.split(".", 1)[0]
|
|
141
|
+
samples.append({
|
|
142
|
+
"name": entry.name,
|
|
143
|
+
"target": target,
|
|
144
|
+
"source": str(entry),
|
|
145
|
+
"label": _SAMPLE_LABELS.get(entry.name, entry.name),
|
|
146
|
+
})
|
|
147
|
+
return samples
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_sample(name: str) -> HookSample | None:
|
|
151
|
+
for sample in list_samples():
|
|
152
|
+
if sample["name"] == name:
|
|
153
|
+
return sample
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _hooks_dir(project: str) -> Path:
|
|
158
|
+
repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
|
|
159
|
+
return Path(repo.path) / "hooks"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _target_path(project: str, sample: HookSample) -> Path:
|
|
163
|
+
return _hooks_dir(project) / sample["target"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def status(project: str, sample_name: str) -> HookStatus:
|
|
167
|
+
sample: HookSample | None = get_sample(sample_name)
|
|
168
|
+
if sample is None:
|
|
169
|
+
raise KeyError(f"Unknown hook sample: {sample_name}")
|
|
170
|
+
target: Path = _target_path(project, sample)
|
|
171
|
+
if not target.is_file():
|
|
172
|
+
return HookStatus.NOT_INSTALLED
|
|
173
|
+
try:
|
|
174
|
+
installed_bytes: bytes = target.read_bytes()
|
|
175
|
+
source_bytes: bytes = Path(sample["source"]).read_bytes()
|
|
176
|
+
except OSError:
|
|
177
|
+
return HookStatus.NOT_INSTALLED
|
|
178
|
+
return HookStatus.INSTALLED if installed_bytes == source_bytes else HookStatus.DIFFERENT
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_installed(project: str, sample_name: str) -> bool:
|
|
182
|
+
return status(project, sample_name) == HookStatus.INSTALLED
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def install(project: str, sample_name: str) -> HookStatus:
|
|
186
|
+
"""Copy the sample to the hooks dir. Refuses to overwrite a file with different content."""
|
|
187
|
+
sample: HookSample | None = get_sample(sample_name)
|
|
188
|
+
if sample is None:
|
|
189
|
+
raise KeyError(f"Unknown hook sample: {sample_name}")
|
|
190
|
+
current: HookStatus = status(project, sample_name)
|
|
191
|
+
if current == HookStatus.DIFFERENT:
|
|
192
|
+
return current
|
|
193
|
+
target: Path = _target_path(project, sample)
|
|
194
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
shutil.copyfile(sample["source"], target)
|
|
196
|
+
try:
|
|
197
|
+
mode: int = target.stat().st_mode
|
|
198
|
+
target.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
199
|
+
except OSError:
|
|
200
|
+
pass
|
|
201
|
+
return status(project, sample_name)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def remove(project: str, sample_name: str) -> HookStatus:
|
|
205
|
+
"""Delete the hook file only if its content still matches the sample (preserves custom hooks)."""
|
|
206
|
+
sample: HookSample | None = get_sample(sample_name)
|
|
207
|
+
if sample is None:
|
|
208
|
+
raise KeyError(f"Unknown hook sample: {sample_name}")
|
|
209
|
+
current: HookStatus = status(project, sample_name)
|
|
210
|
+
if current == HookStatus.INSTALLED:
|
|
211
|
+
_target_path(project, sample).unlink(missing_ok=True)
|
|
212
|
+
return status(project, sample_name)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def list_bundles() -> list[HookBundle]:
|
|
216
|
+
bundles: list[HookBundle] = []
|
|
217
|
+
for name, member_names in HOOK_BUNDLES.items():
|
|
218
|
+
members: list[HookSample] = []
|
|
219
|
+
for member_name in member_names:
|
|
220
|
+
sample: HookSample | None = get_sample(member_name)
|
|
221
|
+
if sample is not None:
|
|
222
|
+
members.append(sample)
|
|
223
|
+
bundles.append({
|
|
224
|
+
"name": name,
|
|
225
|
+
"label": _BUNDLE_LABELS.get(name, name),
|
|
226
|
+
"members": members,
|
|
227
|
+
})
|
|
228
|
+
return bundles
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_bundle(name: str) -> HookBundle | None:
|
|
232
|
+
for bundle in list_bundles():
|
|
233
|
+
if bundle["name"] == name:
|
|
234
|
+
return bundle
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def bundle_status(project: str, bundle_name: str) -> HookStatus:
|
|
239
|
+
"""Aggregate status for a bundle: DIFFERENT if any member conflicts, INSTALLED if all are,
|
|
240
|
+
otherwise NOT_INSTALLED (treats partial installs as not-installed so re-clicking install
|
|
241
|
+
completes the bundle)."""
|
|
242
|
+
bundle: HookBundle | None = get_bundle(bundle_name)
|
|
243
|
+
if bundle is None:
|
|
244
|
+
raise KeyError(f"Unknown hook bundle: {bundle_name}")
|
|
245
|
+
if not bundle["members"]:
|
|
246
|
+
return HookStatus.NOT_INSTALLED
|
|
247
|
+
statuses: list[HookStatus] = [status(project, member["name"]) for member in bundle["members"]]
|
|
248
|
+
if any(s == HookStatus.DIFFERENT for s in statuses):
|
|
249
|
+
return HookStatus.DIFFERENT
|
|
250
|
+
if all(s == HookStatus.INSTALLED for s in statuses):
|
|
251
|
+
return HookStatus.INSTALLED
|
|
252
|
+
return HookStatus.NOT_INSTALLED
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def install_bundle(project: str, bundle_name: str) -> HookStatus:
|
|
256
|
+
"""Install every member of the bundle. Refuses (no-op) if any target holds a different file."""
|
|
257
|
+
bundle: HookBundle | None = get_bundle(bundle_name)
|
|
258
|
+
if bundle is None:
|
|
259
|
+
raise KeyError(f"Unknown hook bundle: {bundle_name}")
|
|
260
|
+
for member in bundle["members"]:
|
|
261
|
+
if status(project, member["name"]) == HookStatus.DIFFERENT:
|
|
262
|
+
return HookStatus.DIFFERENT
|
|
263
|
+
for member in bundle["members"]:
|
|
264
|
+
install(project, member["name"])
|
|
265
|
+
return bundle_status(project, bundle_name)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def remove_bundle(project: str, bundle_name: str) -> HookStatus:
|
|
269
|
+
"""Remove every member of the bundle. Refuses (no-op) if any target holds a different file."""
|
|
270
|
+
bundle: HookBundle | None = get_bundle(bundle_name)
|
|
271
|
+
if bundle is None:
|
|
272
|
+
raise KeyError(f"Unknown hook bundle: {bundle_name}")
|
|
273
|
+
for member in bundle["members"]:
|
|
274
|
+
if status(project, member["name"]) == HookStatus.DIFFERENT:
|
|
275
|
+
return HookStatus.DIFFERENT
|
|
276
|
+
for member in bundle["members"]:
|
|
277
|
+
remove(project, member["name"])
|
|
278
|
+
return bundle_status(project, bundle_name)
|