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
import re
from sys import stderr
import pygit2
from pygittools.hooks import CommitMsg, Hook, HookResult, PreReceive, RefUpdate
NULL_OID: str = "0" * 40
"""
Commit-Msg Pattern Hook (Client-side)
Validates the commit message against a regular expression.
"""
class CommitMsgPattern(CommitMsg):
def __init__(self, repo: pygit2.Repository, pattern: str):
super().__init__(repo)
self.exp = re.compile(pattern)
def run(self, message_file: str) -> HookResult:
with open(message_file) as f:
message = f.read()
if not self.exp.match(message):
stderr.write(f"Commit message does not match pattern: {self.exp.pattern}\n")
return HookResult.FAILURE
return HookResult.SUCCESS
"""
Update Pattern Hook (Server-side)
Invoked by git-receive-pack once per ref being updated, before the ref is updated.
Use this hook to:
- Enforce fast-forward only (reject non-FF updates)
- Implement per-ref access control
- Log or validate old → new for specific refs
Takes three parameters: ref name, old object name, new object name.
"""
class UpdatePattern(Hook):
def __init__(self, repo: pygit2.Repository, ref_pattern: str, msg_pattern: str):
super().__init__(repo)
self.ref_exp = re.compile(ref_pattern)
self.msg_exp = re.compile(msg_pattern)
def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
if self.ref_exp.match(ref_name):
commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
if not self.msg_exp.match(commit.message):
stderr.write(f"Commit message does not match pattern: {self.msg_exp.pattern}\n")
return HookResult.FAILURE
return HookResult.SUCCESS
"""
Pre-Receive Protected Pattern Hook (Server-side)
Invoked by git-receive-pack once before any refs are updated. For each ref update that
matches a protected ref pattern, walks every commit being introduced (old..new) and
validates non-merge commit messages against a pattern. Merge commits are skipped by
default; set reject_merge_commits to refuse merge commits and require squash/rebase.
"""
class PreReceiveProtectedPattern(PreReceive):
def __init__(
self,
repo: pygit2.Repository,
ref_pattern: str,
msg_pattern: str,
*,
reject_merge_commits: bool = False,
) -> None:
super().__init__(repo)
self.ref_exp: re.Pattern[str] = re.compile(ref_pattern)
self.msg_exp: re.Pattern[str] = re.compile(msg_pattern)
self.reject_merge_commits: bool = reject_merge_commits
def run(self, ref_updates: list[RefUpdate]) -> HookResult:
for ref_name, old_oid, new_oid in ref_updates:
if not self.ref_exp.match(ref_name):
continue
result: HookResult = self._validate_ref_update(ref_name, old_oid, new_oid)
if result != HookResult.SUCCESS:
return result
return HookResult.SUCCESS
def _validate_ref_update(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
if new_oid == NULL_OID:
return HookResult.SUCCESS
try:
new_commit: pygit2.Commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
except (KeyError, pygit2.GitError):
stderr.write(f"Invalid commit {new_oid} for {ref_name}\n")
return HookResult.FAILURE
old_commit: pygit2.Commit | None = None
if old_oid != NULL_OID:
try:
old_commit = self.repo.revparse_single(old_oid).peel(pygit2.Commit)
except (KeyError, pygit2.GitError):
old_commit = None
for commit in self._commits_in_range(new_commit, old_commit):
if len(commit.parents) > 1:
if self.reject_merge_commits:
stderr.write(
f"Merge commit {commit.id} rejected on protected ref {ref_name}; "
"use squash or rebase instead.\n",
)
return HookResult.FAILURE
continue
if not self.msg_exp.match(commit.message):
stderr.write(
f"Commit {commit.id} on {ref_name} does not match pattern: {self.msg_exp.pattern}\n",
)
return HookResult.FAILURE
return HookResult.SUCCESS
def _commits_in_range(
self,
new_commit: pygit2.Commit,
old_commit: pygit2.Commit | None,
) -> list[pygit2.Commit]:
excluded: set[pygit2.Oid] = set()
if old_commit is not None:
for ancestor in self.repo.walk(old_commit.id):
excluded.add(ancestor.id)
commits: list[pygit2.Commit] = []
for commit in self.repo.walk(new_commit.id):
if commit.id in excluded:
continue
commits.append(commit)
return commits