diff --git a/pygittools/hook_samples/README.md b/pygittools/hook_samples/README.md
index 688fbf3..403e138 100644
--- a/pygittools/hook_samples/README.md
+++ b/pygittools/hook_samples/README.md
@@ -15,5 +15,17 @@ every local commit (client-side). They share two environment variables:
   directory name with `.git` stripped, or for non-bare clones the working-tree directory
   name)
 
-The pygitweb summary page can install both at once via the **Update Hook** row, which
-manages them as the `update` bundle.
+The pygitweb summary page can install both at once via the **Hooks** row (the `update`
+bundle installs `post-commit.notify` and `post-receive.notify` together).
+
+## pre-receive.protected-pattern
+
+Reject pushes to protected refs when any newly introduced non-merge commit message does
+not match a pattern. Merge commits are skipped by default; set `PYGITWEB_REJECT_MERGE_COMMITS=1`
+to refuse merge commits and require squash or rebase instead.
+
+Environment variables:
+
+- `PYGITWEB_PROTECTED_REF_PATTERN` — refs to protect (default `^refs/heads/(main|master)$`)
+- `PYGITWEB_COMMIT_MSG_PATTERN` — message regex (default `^(\S+): (.+)`)
+- `PYGITWEB_REJECT_MERGE_COMMITS` — set to `1`/`true`/`yes` to reject merge commits
diff --git a/pygittools/hook_samples/pre-receive.protected-pattern b/pygittools/hook_samples/pre-receive.protected-pattern
new file mode 100644
index 0000000..a6956bb
--- /dev/null
+++ b/pygittools/hook_samples/pre-receive.protected-pattern
@@ -0,0 +1,37 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+
+__VERSION__ = "1"
+
+import os
+import sys
+
+import pygit2
+
+from pygittools.hooks import HookResult, parse_ref_updates
+from pygittools.hooks_patterns import PreReceiveProtectedPattern
+
+PROTECTED_REF_PATTERN = os.environ.get("PYGITWEB_PROTECTED_REF_PATTERN", r"^refs/heads/(main|master)$")
+COMMIT_MSG_PATTERN = os.environ.get("PYGITWEB_COMMIT_MSG_PATTERN", r"^(\S+): (.+)")
+REJECT_MERGE_COMMITS = os.environ.get("PYGITWEB_REJECT_MERGE_COMMITS", "").lower() in {
+	"1",
+	"true",
+	"yes",
+}
+
+
+def main() -> int:
+	repo = pygit2.Repository(".")
+	updates = parse_ref_updates(sys.stdin)
+	result = PreReceiveProtectedPattern(
+		repo,
+		PROTECTED_REF_PATTERN,
+		COMMIT_MSG_PATTERN,
+		reject_merge_commits=REJECT_MERGE_COMMITS,
+	).run(updates)
+	return int(result.value)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/pygittools/hooks_patterns.py b/pygittools/hooks_patterns.py
index e35f30c..15982ce 100644
--- a/pygittools/hooks_patterns.py
+++ b/pygittools/hooks_patterns.py
@@ -3,7 +3,9 @@ from sys import stderr
 
 import pygit2
 
-from pygittools.hooks import CommitMsg, Hook, HookResult
+from pygittools.hooks import CommitMsg, Hook, HookResult, PreReceive, RefUpdate
+
+NULL_OID: str = "0" * 40
 
 """
 Commit-Msg Pattern Hook (Client-side)
@@ -49,3 +51,84 @@ class UpdatePattern(Hook):
 				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
diff --git a/pygittools/hooks_patterns_test.py b/pygittools/hooks_patterns_test.py
new file mode 100644
index 0000000..eb3c94d
--- /dev/null
+++ b/pygittools/hooks_patterns_test.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pygit2 import Oid, Repository, Signature, init_repository
+
+from pygittools.hooks import HookResult
+from pygittools.hooks_patterns import NULL_OID, PreReceiveProtectedPattern
+
+MSG_PATTERN: str = r"^(\S+): (.+)"
+PROTECTED_MAIN: str = r"^refs/heads/main$"
+SIG = Signature("test", "test@example.com")
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> Repository:
+	repo_path = tmp_path / "repo.git"
+	repo_path.mkdir()
+	return init_repository(str(repo_path), bare=True)
+
+
+def _commit(repo: Repository, message: str, parents: list[Oid] | None = None) -> Oid:
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	return repo.create_commit(None, SIG, SIG, message, tree, parents or [])
+
+
+def _run(
+	repo: Repository,
+	ref: str,
+	old_oid: str,
+	new_oid: str,
+	*,
+	reject_merge_commits: bool = False,
+) -> HookResult:
+	hook = PreReceiveProtectedPattern(
+		repo,
+		PROTECTED_MAIN,
+		MSG_PATTERN,
+		reject_merge_commits=reject_merge_commits,
+	)
+	return hook.run([(ref, old_oid, new_oid)])
+
+
+def test_ignores_unprotected_ref(repo: Repository) -> None:
+	bad = _commit(repo, "not conventional")
+	assert _run(repo, "refs/heads/feature", NULL_OID, str(bad)) == HookResult.SUCCESS
+
+
+def test_accepts_valid_commit_on_protected_ref(repo: Repository) -> None:
+	good = _commit(repo, "feat: add thing")
+	assert _run(repo, "refs/heads/main", NULL_OID, str(good)) == HookResult.SUCCESS
+
+
+def test_rejects_invalid_commit_on_protected_ref(repo: Repository) -> None:
+	bad = _commit(repo, "not conventional")
+	assert _run(repo, "refs/heads/main", NULL_OID, str(bad)) == HookResult.FAILURE
+
+
+def test_walks_every_commit_in_fast_forward_push(repo: Repository) -> None:
+	good = _commit(repo, "feat: first")
+	bad = _commit(repo, "not conventional", [good])
+	assert _run(repo, "refs/heads/main", str(good), str(bad)) == HookResult.FAILURE
+
+
+def test_skips_merge_commit_message_by_default(repo: Repository) -> None:
+	base = _commit(repo, "feat: base")
+	feature = _commit(repo, "feat: side", [base])
+	merge = _commit(repo, "Merge branch 'feature'", [base, feature])
+	assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.SUCCESS
+
+
+def test_rejects_merge_commit_when_configured(repo: Repository) -> None:
+	base = _commit(repo, "feat: base")
+	feature = _commit(repo, "feat: side", [base])
+	merge = _commit(repo, "Merge branch 'feature'", [base, feature])
+	result = _run(repo, "refs/heads/main", str(base), str(merge), reject_merge_commits=True)
+	assert result == HookResult.FAILURE
+
+
+def test_validates_non_merge_commits_introduced_by_merge(repo: Repository) -> None:
+	base = _commit(repo, "feat: base")
+	bad = _commit(repo, "bad message on feature", [base])
+	merge = _commit(repo, "Merge branch 'feature'", [base, bad])
+	assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.FAILURE
+
+
+def test_allows_ref_deletion(repo: Repository) -> None:
+	base = _commit(repo, "feat: base")
+	assert _run(repo, "refs/heads/main", str(base), NULL_OID) == HookResult.SUCCESS
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index 9130c88..7593640 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -31,6 +31,7 @@ _SAMPLE_LABELS: dict[str, str] = {
 	"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",
 }
 
 
