1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
"""
Install, remove, and inspect pygittools hook samples in a project's hooks directory.
A "sample" is a file in `pygittools/hook_samples/` whose name encodes the target git
hook as the prefix before the first dot (e.g. `post-receive.notify` installs as
`<hooks_dir>/post-receive`). The hooks directory is whichever path pygit2 reports for
the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import stat
from enum import StrEnum
from pathlib import Path
from typing import TypedDict
import pygit2
import pygittools
from pygitweb.config import settings
SAMPLES_DIR: Path = Path(pygittools.__file__).parent / "hook_samples"
_VERSION_RE: re.Pattern[str] = re.compile(r"""^__VERSION__\s*=\s*(['"])(.*?)\1""", re.MULTILINE)
_SAMPLE_LABELS: dict[str, str] = {
"post-commit.notify": "Post-commit Notify",
"post-receive.notify": "Post-receive Notify",
"pre-commit.ruff": "Ruff Pre-commit",
"commit-msg.pattern": "Commit Message Pattern",
"pre-receive.protected-pattern": "Protected Branch Commit Messages",
"post-commit.push-remotes": "Post-commit Push Remotes",
}
HOOK_BUNDLES: dict[str, list[str]] = {
# "Update Hook" wires both server (post-receive on push) and client (post-commit on local
# commit) sides into the long-poll change queue, so subscribers wake regardless of where
# the commit was made.
"update": ["post-commit.notify", "post-receive.notify"],
}
_BUNDLE_LABELS: dict[str, str] = {
"update": "Update Hook",
}
class HookStatus(StrEnum):
INSTALLED = "installed"
NOT_INSTALLED = "not_installed"
DIFFERENT = "different"
class HookSample(TypedDict):
name: str
target: str
source: str
label: str
class HookBundle(TypedDict):
name: str
label: str
members: list[HookSample]
class InstalledHookInfo(TypedDict):
name: str
label: str
target: str
status: str
version: str | None
content_hash: str | None
def content_hash(path: Path) -> str:
"""Return the SHA-256 hex digest of a hook file's contents."""
return hashlib.sha256(path.read_bytes()).hexdigest()
def sample_content_hash(sample_name: str) -> str:
sample: HookSample | None = get_sample(sample_name)
if sample is None:
raise KeyError(f"Unknown hook sample: {sample_name}")
return content_hash(Path(sample["source"]))
def read_hook_version(path: Path) -> str | None:
"""Return the __VERSION__ string from a hook file, if present."""
try:
text: str = path.read_text(encoding="utf-8")
except OSError:
return None
match: re.Match[str] | None = _VERSION_RE.search(text)
if match is None:
return None
return match.group(2)
def get_installed_version(project: str, sample_name: str) -> str | None:
sample: HookSample | None = get_sample(sample_name)
if sample is None:
raise KeyError(f"Unknown hook sample: {sample_name}")
target: Path = _target_path(project, sample)
if target.is_file():
version: str | None = read_hook_version(target)
if version is not None:
return version
return read_hook_version(Path(sample["source"]))
def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
"""Return pygittools hook samples that are installed or conflict with a custom hook."""
installed: list[InstalledHookInfo] = []
for sample in list_samples():
st: HookStatus = status(project, sample["name"])
if st == HookStatus.NOT_INSTALLED:
continue
if st == HookStatus.DIFFERENT:
owner: str | None = _installed_sample_for_target(project, sample["target"])
if owner is not None and owner != sample["name"]:
continue
target_path: Path = _target_path(project, sample)
version: str | None = None
if st == HookStatus.INSTALLED:
version = get_installed_version(project, sample["name"])
else:
version = read_hook_version(target_path)
installed_hash: str | None = None
if target_path.is_file():
try:
installed_hash = content_hash(target_path)
except OSError:
pass
installed.append({
"name": sample["name"],
"label": sample["label"],
"target": sample["target"],
"status": st.value,
"version": version,
"content_hash": installed_hash,
})
return installed
def list_installable_hooks(project: str) -> list[HookSample]:
"""Return pygittools hook samples that can still be installed in this project."""
installable: list[HookSample] = []
for sample in list_samples():
if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
installable.append(sample)
return installable
def list_samples() -> list[HookSample]:
if not SAMPLES_DIR.is_dir():
return []
samples: list[HookSample] = []
for entry in sorted(SAMPLES_DIR.iterdir()):
if not entry.is_file() or "." not in entry.name or entry.name.lower().endswith(".md"):
continue
target: str = entry.name.split(".", 1)[0]
samples.append({
"name": entry.name,
"target": target,
"source": str(entry),
"label": _SAMPLE_LABELS.get(entry.name, entry.name),
})
return samples
def get_sample(name: str) -> HookSample | None:
for sample in list_samples():
if sample["name"] == name:
return sample
return None
def _hooks_dir(project: str) -> Path:
repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
return Path(repo.path) / "hooks"
def _target_path(project: str, sample: HookSample) -> Path:
return _hooks_dir(project) / sample["target"]
def _installed_sample_for_target(project: str, target: str) -> str | None:
"""Return the sample name whose content is installed at target, if any."""
target_path: Path = _hooks_dir(project) / target
if not target_path.is_file():
return None
try:
installed_hash: str = content_hash(target_path)
except OSError:
return None
for sample in list_samples():
if sample["target"] != target:
continue
try:
if content_hash(Path(sample["source"])) == installed_hash:
return sample["name"]
except OSError:
continue
return None
def status(project: str, sample_name: str) -> HookStatus:
sample: HookSample | None = get_sample(sample_name)
if sample is None:
raise KeyError(f"Unknown hook sample: {sample_name}")
target: Path = _target_path(project, sample)
if not target.is_file():
return HookStatus.NOT_INSTALLED
try:
installed_bytes: bytes = target.read_bytes()
source_bytes: bytes = Path(sample["source"]).read_bytes()
except OSError:
return HookStatus.NOT_INSTALLED
return HookStatus.INSTALLED if installed_bytes == source_bytes else HookStatus.DIFFERENT
def is_installed(project: str, sample_name: str) -> bool:
return status(project, sample_name) == HookStatus.INSTALLED
def install(project: str, sample_name: str) -> HookStatus:
"""Copy the sample to the hooks dir. Refuses to overwrite a file with different content."""
sample: HookSample | None = get_sample(sample_name)
if sample is None:
raise KeyError(f"Unknown hook sample: {sample_name}")
current: HookStatus = status(project, sample_name)
if current == HookStatus.DIFFERENT:
return current
target: Path = _target_path(project, sample)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(sample["source"], target)
try:
mode: int = target.stat().st_mode
target.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
pass
return status(project, sample_name)
def remove(project: str, sample_name: str) -> HookStatus:
"""Delete the hook file only if its content still matches the sample (preserves custom hooks)."""
sample: HookSample | None = get_sample(sample_name)
if sample is None:
raise KeyError(f"Unknown hook sample: {sample_name}")
current: HookStatus = status(project, sample_name)
if current == HookStatus.INSTALLED:
_target_path(project, sample).unlink(missing_ok=True)
return status(project, sample_name)
def list_bundles() -> list[HookBundle]:
bundles: list[HookBundle] = []
for name, member_names in HOOK_BUNDLES.items():
members: list[HookSample] = []
for member_name in member_names:
sample: HookSample | None = get_sample(member_name)
if sample is not None:
members.append(sample)
bundles.append({
"name": name,
"label": _BUNDLE_LABELS.get(name, name),
"members": members,
})
return bundles
def get_bundle(name: str) -> HookBundle | None:
for bundle in list_bundles():
if bundle["name"] == name:
return bundle
return None
def bundle_status(project: str, bundle_name: str) -> HookStatus:
"""Aggregate status for a bundle: DIFFERENT if any member conflicts, INSTALLED if all are,
otherwise NOT_INSTALLED (treats partial installs as not-installed so re-clicking install
completes the bundle)."""
bundle: HookBundle | None = get_bundle(bundle_name)
if bundle is None:
raise KeyError(f"Unknown hook bundle: {bundle_name}")
if not bundle["members"]:
return HookStatus.NOT_INSTALLED
statuses: list[HookStatus] = [status(project, member["name"]) for member in bundle["members"]]
if any(s == HookStatus.DIFFERENT for s in statuses):
return HookStatus.DIFFERENT
if all(s == HookStatus.INSTALLED for s in statuses):
return HookStatus.INSTALLED
return HookStatus.NOT_INSTALLED
def install_bundle(project: str, bundle_name: str) -> HookStatus:
"""Install every member of the bundle. Refuses (no-op) if any target holds a different file."""
bundle: HookBundle | None = get_bundle(bundle_name)
if bundle is None:
raise KeyError(f"Unknown hook bundle: {bundle_name}")
for member in bundle["members"]:
if status(project, member["name"]) == HookStatus.DIFFERENT:
return HookStatus.DIFFERENT
for member in bundle["members"]:
install(project, member["name"])
return bundle_status(project, bundle_name)
def remove_bundle(project: str, bundle_name: str) -> HookStatus:
"""Remove every member of the bundle. Refuses (no-op) if any target holds a different file."""
bundle: HookBundle | None = get_bundle(bundle_name)
if bundle is None:
raise KeyError(f"Unknown hook bundle: {bundle_name}")
for member in bundle["members"]:
if status(project, member["name"]) == HookStatus.DIFFERENT:
return HookStatus.DIFFERENT
for member in bundle["members"]:
remove(project, member["name"])
return bundle_status(project, bundle_name)