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
@@ -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)