diff --git a/pygittools/README.md b/pygittools/README.md
index 84e5e18..2e1750f 100644
--- a/pygittools/README.md
+++ b/pygittools/README.md
@@ -1,3 +1,16 @@
 # PyGitTools
 
 Authentication, hooks, and pygit2 workflows used by PyGitWeb. Can be used headless for many purposes.
+
+## Hook framework
+
+`pygittools.hooks` provides a thin `Hook` base class plus one subclass per git hook type
+(client- and server-side). Receive-pack hooks (`PreReceive`, `PostReceive`) parse their
+ref-update lines via `parse_ref_updates(stdin)`.
+
+`pygittools.hooks_notify` adds `PreReceiveNotify` / `PostReceiveNotify`: minimal hook
+variants that POST `?project=<name>` to a pygitweb `/_internal/notify` endpoint so its
+long-poll `updates=true` subscribers wake up immediately on each push. Notification
+failures never reject the push.
+
+See `hook_samples/` for ready-to-use scripts.
diff --git a/pygittools/hook_samples/README.md b/pygittools/hook_samples/README.md
index 250ed5b..688fbf3 100644
--- a/pygittools/hook_samples/README.md
+++ b/pygittools/hook_samples/README.md
@@ -1,5 +1,19 @@
 # Sample Hooks
 
-Place these as desired in .git/hooks/ WITHOUT the suffix.
+Place these as desired in `.git/hooks/` WITHOUT the suffix.
 
 You will just need UV with pygittools in your virtual environment.
+
+## post-receive.notify and post-commit.notify
+
+Wake pygitweb's long-poll subscribers (`updates=true`) on every push (server-side) and
+every local commit (client-side). They share two environment variables:
+
+- `PYGITWEB_NOTIFY_URL` — the running server's notify endpoint (default
+  `http://127.0.0.1:8000/_internal/notify`)
+- `PYGITWEB_PROJECT` — project path as known to pygitweb (default: the bare repo's
+  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.
diff --git a/pygittools/hook_samples/post-commit.notify b/pygittools/hook_samples/post-commit.notify
new file mode 100644
index 0000000..ff97780
--- /dev/null
+++ b/pygittools/hook_samples/post-commit.notify
@@ -0,0 +1,22 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+
+import os
+
+import pygit2
+
+from pygittools.hooks_notify import PostCommitNotify
+
+NOTIFY_URL = os.environ.get("PYGITWEB_NOTIFY_URL", "http://127.0.0.1:8000/_internal/notify")
+PROJECT = os.environ.get("PYGITWEB_PROJECT") or os.path.basename(os.getcwd()).removesuffix(".git")
+
+
+def main() -> int:
+	repo = pygit2.Repository(".")
+	result = PostCommitNotify(repo, NOTIFY_URL, PROJECT).run()
+	return int(result.value)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/pygittools/hook_samples/post-receive.notify b/pygittools/hook_samples/post-receive.notify
new file mode 100644
index 0000000..dc196ce
--- /dev/null
+++ b/pygittools/hook_samples/post-receive.notify
@@ -0,0 +1,25 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+
+import os
+import sys
+
+import pygit2
+
+from pygittools.hooks import parse_ref_updates
+from pygittools.hooks_notify import PostReceiveNotify
+
+NOTIFY_URL = os.environ.get("PYGITWEB_NOTIFY_URL", "http://127.0.0.1:8000/_internal/notify")
+PROJECT = os.environ.get("PYGITWEB_PROJECT") or os.path.basename(os.getcwd()).removesuffix(".git")
+
+
+def main() -> int:
+	repo = pygit2.Repository(".")
+	updates = parse_ref_updates(sys.stdin)
+	result = PostReceiveNotify(repo, NOTIFY_URL, PROJECT).run(updates)
+	return int(result.value)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/pygittools/hooks.py b/pygittools/hooks.py
index 53ebf0e..fd36380 100644
--- a/pygittools/hooks.py
+++ b/pygittools/hooks.py
@@ -1,5 +1,7 @@
+import sys
 from dataclasses import dataclass
 from enum import Enum
+from typing import IO
 
 import pygit2
 
@@ -14,6 +16,20 @@ class Hook:
 	repo: pygit2.Repository
 
 
+RefUpdate = tuple[str, str, str]
+
+
+def parse_ref_updates(stream: IO[str] | None = None) -> list[RefUpdate]:
+	"""Parse `<old-oid> <new-oid> <ref-name>` lines (one per ref) from a receive-pack hook's stdin."""
+	source: IO[str] = stream if stream is not None else sys.stdin
+	updates: list[RefUpdate] = []
+	for raw in source:
+		parts: list[str] = raw.strip().split(maxsplit=2)
+		if len(parts) == 3:
+			updates.append((parts[0], parts[1], parts[2]))
+	return updates
+
+
 """
 Pre-Commit Hook (Client-side)
 Runs before a commit is made, before a commit message is written (if not supplied by -m).
@@ -269,6 +285,43 @@ class PreAutoGc(Hook):
 		return HookResult.SUCCESS
 
 
+"""
+Pre-Receive Hook (Server-side)
+Invoked by git-receive-pack once before any refs are updated. Receives ref updates on
+stdin as `<old-oid> <new-oid> <ref-name>` lines (one per ref). Exiting non-zero rejects
+the entire push (no refs are updated).
+Use this hook to:
+- Reject pushes that violate global policy (e.g. force-push to protected refs)
+- Notify a long-poll change queue so subscribers can react quickly
+"""
+
+
+class PreReceive(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Post-Receive Hook (Server-side)
+Invoked by git-receive-pack once after all refs have been updated. Receives ref updates
+on stdin in the same format as pre-receive. Exit code does not affect the receive.
+Use this hook to:
+- Notify caches / long-poll subscribers (refs are visible at this point)
+- Trigger CI, mirroring, or webhook delivery
+"""
+
+
+class PostReceive(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+		return HookResult.SUCCESS
+
+
 # -----------------------------------------------------------------------------
 # Hooks skipped (not implemented in this module)
 # -----------------------------------------------------------------------------
@@ -278,8 +331,6 @@ class PreAutoGc(Hook):
 #   - pre-applypatch   (no params; used by git am)
 #   - post-applypatch  (no params; used by git am)
 #
-# Stdin-only or protocol hooks (skipped: no string parameters to pass to run()):
-#   - pre-receive         (no args; ref updates on stdin)
-#   - post-receive        (no args; ref updates on stdin)
+# Other stdin-only / protocol hooks (skipped: pkt-line or transactional state):
 #   - reference-transaction (state string + ref updates on stdin)
 #   - proc-receive        (pkt-line protocol on stdin/stdout)
diff --git a/pygittools/hooks_notify.py b/pygittools/hooks_notify.py
new file mode 100644
index 0000000..6ae1093
--- /dev/null
+++ b/pygittools/hooks_notify.py
@@ -0,0 +1,101 @@
+"""
+Server-side hooks that notify a running pygitweb instance about ref changes so its
+long-polling `updates=true` subscribers can wake up immediately.
+"""
+
+from __future__ import annotations
+
+import urllib.error
+import urllib.parse
+import urllib.request
+from sys import stderr
+
+import pygit2
+
+from pygittools.hooks import HookResult, PostCommit, PostReceive, PreReceive, RefUpdate
+
+DEFAULT_NOTIFY_TIMEOUT_SECONDS: float = 5.0
+
+
+def _post_notify(notify_url: str, project: str, timeout: float) -> bool:
+	url: str = f"{notify_url.rstrip('/')}?{urllib.parse.urlencode({'project': project})}"
+	req: urllib.request.Request = urllib.request.Request(url, data=b"", method="POST")
+	try:
+		with urllib.request.urlopen(req, timeout=timeout) as resp:
+			return 200 <= resp.status < 300
+	except (urllib.error.URLError, OSError, ValueError) as exc:
+		stderr.write(f"notify {url!r} failed: {exc}\n")
+		return False
+
+
+class PreReceiveNotify(PreReceive):
+	"""Pre-receive hook variant that POSTs `<notify_url>?project=<project>` to wake subscribers.
+
+	Notification failures never reject the push: they are reported on stderr only.
+	Refs are not yet visible at pre-receive time, so subscribers may briefly see stale data.
+	Prefer PostReceiveNotify for change-queue use cases.
+	"""
+
+	def __init__(
+		self,
+		repo: pygit2.Repository,
+		notify_url: str,
+		project: str,
+		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+	) -> None:
+		super().__init__(repo)
+		self.notify_url: str = notify_url
+		self.project: str = project
+		self.timeout: float = timeout
+
+	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+		if not ref_updates:
+			return HookResult.SUCCESS
+		_post_notify(self.notify_url, self.project, self.timeout)
+		return HookResult.SUCCESS
+
+
+class PostReceiveNotify(PostReceive):
+	"""Post-receive hook variant that POSTs `<notify_url>?project=<project>` after refs update."""
+
+	def __init__(
+		self,
+		repo: pygit2.Repository,
+		notify_url: str,
+		project: str,
+		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+	) -> None:
+		super().__init__(repo)
+		self.notify_url: str = notify_url
+		self.project: str = project
+		self.timeout: float = timeout
+
+	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+		if not ref_updates:
+			return HookResult.SUCCESS
+		_post_notify(self.notify_url, self.project, self.timeout)
+		return HookResult.SUCCESS
+
+
+class PostCommitNotify(PostCommit):
+	"""Post-commit hook variant for local clients: POSTs to a pygitweb instance after each commit.
+
+	Pair with PostReceiveNotify on the server so long-poll subscribers wake on both push events
+	and local commits made in working clones served by the same pygitweb instance.
+	"""
+
+	def __init__(
+		self,
+		repo: pygit2.Repository,
+		notify_url: str,
+		project: str,
+		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+	) -> None:
+		super().__init__(repo)
+		self.notify_url: str = notify_url
+		self.project: str = project
+		self.timeout: float = timeout
+
+	def run(self) -> HookResult:
+		_post_notify(self.notify_url, self.project, self.timeout)
+		return HookResult.SUCCESS
diff --git a/pygitweb/README.md b/pygitweb/README.md
index 8e3dcf1..ea39442 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -51,6 +51,11 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
 - `GET /opml`: OPML feed list
 - `GET /project/{name}`: Project dispatch (See actions table)
 
+**Hook management routes**
+
+- `GET /project/{name}/hooks`: JSON `{hooks: [...samples...], bundles: [...]}` with current status (`installed`, `not_installed`, `different`) for every pygittools sample and bundle
+- `POST /project/{name}/hook?name=<sample-or-bundle>&op=<add|remove|check>`: install, remove, or check one sample or bundle. `name` may be a sample filename (e.g. `post-receive.notify`) or a bundle name (e.g. `update`, which expands to `post-commit.notify` + `post-receive.notify`). `add`/`remove` refuse to clobber a custom hook with different content (returns `409`)
+
 **Actions**
 
 | Action | Query parameters | URL | Description |
@@ -59,11 +64,11 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
 | tree | `h`, `f` | `GET /project/{project}?a=tree&h=...&f=...` | Directory listing (tree). |
 | blob | `h`, `f` | `GET /project/{project}?a=blob&h=...&f=...` | File view (HTML). |
 | blob_plain | `h`, `f` | `GET /project/{project}?a=blob_plain&h=...&f=...` | Raw file download. |
-| log | `h` | `GET /project/{project}?a=log&h=...` | Commit log. |
-| shortlog | `h` | `GET /project/{project}?a=shortlog&h=...` | Shortlog. |
-| history | `h`, `f` | `GET /project/{project}?a=history&h=...&f=...` | History of a file or path. |
-| heads | — | `GET /project/{project}?a=heads` | List branch heads. |
-| tags | — | `GET /project/{project}?a=tags` | List all tags. |
+| log | `h`, `updates`, `pf` | `GET /project/{project}?a=log&h=...` | Commit log. Supports long-poll subscribe. |
+| shortlog | `h`, `updates`, `pf` | `GET /project/{project}?a=shortlog&h=...` | Shortlog. Supports long-poll subscribe. |
+| history | `h`, `f`, `updates`, `pf` | `GET /project/{project}?a=history&h=...&f=...` | History of a file or path. Supports long-poll subscribe. |
+| heads | `updates`, `pf` | `GET /project/{project}?a=heads` | List branch heads. Supports long-poll subscribe. |
+| tags | `updates`, `pf` | `GET /project/{project}?a=tags` | List all tags. Supports long-poll subscribe. |
 | tag | `h` | `GET /project/{project}?a=tag&h=...` | Single tag view (tag ref or hash). |
 | commit | — | `GET /project/{project}?a=commit` | Commit information. |
 | commitdiff | — | `GET /project/{project}?a=commitdiff` | Commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html)). |
@@ -102,3 +107,35 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
 - `by_tag` → ctag
 - `ds` → diff_style
 - `pf` → project_filter
+- `updates` → long-poll subscription flag (`true` parks the request up to 30s; returns `304 Not Modified` on timeout, or the action data when the queue is notified)
+
+## Live updates (long polling)
+
+Supported actions (`history`, `log`, `shortlog`, `heads`, `tags`) accept `updates=true` to
+subscribe to the project's change queue. The request is held for up to 30 seconds:
+
+- A `POST /_internal/notify?project=<name>` (typically from a server-side git hook) wakes
+  matching subscribers, who then receive the freshly-rendered action response.
+- If no notification arrives in 30 seconds, the response is `304 Not Modified` (empty body).
+- Combine with `pf=<prefix>` to subscribe to every project under a path prefix instead of
+  just the URL project (the action is still rendered for the URL project).
+
+The notify endpoint is intended for loopback use by `pygittools` post-receive hooks (see
+`pygittools/hook_samples/post-receive.notify`); production deployments should restrict it
+at the reverse proxy.
+
+## Hook management
+
+The project summary page exposes an **Update Hook** row that installs / removes the
+`update` bundle: `post-receive.notify` (feeds the change queue when refs are pushed) and
+`post-commit.notify` (feeds the change queue when a working clone of this repo records
+a local commit). Either is sufficient to wake long-poll subscribers; installing both
+covers server-side and client-side commit paths.
+
+The same operations are available programmatically via
+`POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
+`INSTALLED` only when every member is installed, `DIFFERENT` if any member's path holds
+a custom hook (the bundle then refuses to install or remove anything to preserve the
+custom hook), otherwise `NOT_INSTALLED`. When `PYGITWEB_AUTH` is set, `add` and `remove`
+require a valid session (`X-Session-Token` header, `?session=` param, or `session`
+cookie); `check` is always allowed.
diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 964e830..8b71802 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -39,6 +39,7 @@ from pygitweb.git_helpers import (
 	parse_commit,
 	parse_tag,
 )
+from pygitweb.hooks_install import HookStatus, bundle_status
 from pygitweb.projects import git_get_project_owner
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
@@ -484,6 +485,43 @@ def git_search_page(project: str) -> HTMLResponse:
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
+UPDATE_HOOK_BUNDLE: str = "update"
+
+
+def _update_hook_cell(project: str, project_enc: str) -> str:
+	"""Render the Update Hook row's value: status text + toggle button driven by hook-install.js.
+
+	The "update" bundle wires both server-side (post-receive) and client-side (post-commit) notify
+	hooks so long-poll subscribers wake on either pushes or local commits in working clones.
+	"""
+	tpl = env.get_template("update_hook_cell.html")
+	try:
+		current: HookStatus = bundle_status(project, UPDATE_HOOK_BUNDLE)
+	except (KeyError, OSError):
+		return tpl.render(mode="unavailable")
+	if current == HookStatus.DIFFERENT:
+		return tpl.render(mode="different")
+	if current == HookStatus.INSTALLED:
+		return tpl.render(
+			mode="toggle",
+			bundle=UPDATE_HOOK_BUNDLE,
+			project_enc=project_enc,
+			state_label="installed",
+			button_label="Remove Update Hook",
+			next_op="remove",
+			button_class="btn btn-sm btn-ghost-secondary hook-toggle",
+		)
+	return tpl.render(
+		mode="toggle",
+		bundle=UPDATE_HOOK_BUNDLE,
+		project_enc=project_enc,
+		state_label="not installed",
+		button_label="Install Update Hook",
+		next_op="add",
+		button_class="btn btn-sm btn-primary hook-toggle",
+	)
+
+
 def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTMLResponse:
 	"""Project summary page. Port of git_summary."""
 	descr = git_get_project_description(project) or ""
@@ -525,6 +563,7 @@ def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTML
 		["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
 		["Board", board_value],
 		["Search", f"<a href='/project/{project_enc}/search'>search files</a>"],
+		["Update Hook", _update_hook_cell(project, project_enc)],
 	]
 	if extra_rows:
 		rows.extend(extra_rows)
@@ -540,6 +579,7 @@ def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTML
 	body_parts.append(f'<div id="summary-readme-container" data-project="{jinja_escape(project)}">')
 	body_parts.append(f"{initial_ref_state['readme_html']}</div>")
 	body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')
+	body_parts.append('<script src="/static/hook-install.js"></script>')
 
 	body_parts.append(POSTAMBLE)
 	return HTMLResponse("".join(body_parts))
diff --git a/pygitweb/change_queue.py b/pygitweb/change_queue.py
new file mode 100644
index 0000000..a9129a2
--- /dev/null
+++ b/pygitweb/change_queue.py
@@ -0,0 +1,71 @@
+"""
+Per-project async change queue for long-polling subscribers.
+
+Producers (typically server-side git hooks via /_internal/notify) call notify(project)
+when a project's refs change. Consumers (action handlers with updates=true) call
+wait_for_changes(projects, timeout) to park their request until any matching project
+is notified or the timeout elapses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+
+class ChangeQueue:
+	"""Per-project notification fan-out using one-shot asyncio.Events per waiter."""
+
+	DEFAULT_TIMEOUT_SECONDS: float = 30.0
+
+	def __init__(self) -> None:
+		self._waiters: dict[str, list[asyncio.Event]] = {}
+		self._lock: asyncio.Lock = asyncio.Lock()
+
+	async def wait_for_changes(
+		self,
+		projects: list[str],
+		timeout: float = DEFAULT_TIMEOUT_SECONDS,
+	) -> str | None:
+		"""Park until any of `projects` is notified, or `timeout` seconds elapse.
+
+		Returns the notified project name, or None on timeout.
+		"""
+		if not projects:
+			await asyncio.sleep(timeout)
+			return None
+		event: asyncio.Event = asyncio.Event()
+		async with self._lock:
+			for project in projects:
+				self._waiters.setdefault(project, []).append(event)
+		try:
+			await asyncio.wait_for(event.wait(), timeout=timeout)
+		except TimeoutError:
+			return None
+		finally:
+			async with self._lock:
+				for project in projects:
+					bucket: list[asyncio.Event] = self._waiters.get(project, [])
+					if event in bucket:
+						bucket.remove(event)
+					if not bucket:
+						self._waiters.pop(project, None)
+		return getattr(event, "_change_queue_project", None)
+
+	async def notify(self, project: str) -> int:
+		"""Wake all waiters subscribed to `project`. Returns the number of waiters woken."""
+		async with self._lock:
+			waiters: list[asyncio.Event] = self._waiters.pop(project, [])
+		for event in waiters:
+			event._change_queue_project = project  # type: ignore[attr-defined]
+			event.set()
+		return len(waiters)
+
+	async def waiter_count(self, project: str | None = None) -> int:
+		"""Diagnostic: count waiters for one project (or total across all projects)."""
+		async with self._lock:
+			if project is not None:
+				return len(self._waiters.get(project, []))
+			return sum(len(bucket) for bucket in self._waiters.values())
+
+
+CHANGE_QUEUE: ChangeQueue = ChangeQueue()
diff --git a/pygitweb/change_queue_routes_test.py b/pygitweb/change_queue_routes_test.py
new file mode 100644
index 0000000..5091e93
--- /dev/null
+++ b/pygitweb/change_queue_routes_test.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import httpx
+import pygit2
+import pytest
+from httpx import ASGITransport
+
+from pygitweb.change_queue import CHANGE_QUEUE
+from pygitweb.config import settings
+from pygitweb.main import app
+
+
+def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
+	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+	index = repo.index
+	index.add("README.md")
+	index.write()
+	tree = index.write_tree()
+	sig = pygit2.Signature("tester", "tester@example.com")
+	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+
+
+@pytest.fixture(scope="module")
+def updates_env(tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+	root: Path = tmp_path_factory.mktemp("updates-routes")
+	alpha_dir: Path = root / "alpha"
+	alpha_dir.mkdir()
+	_create_initial_commit(pygit2.init_repository(str(alpha_dir), bare=False), alpha_dir)
+	beta_dir: Path = root / "group" / "beta"
+	beta_dir.mkdir(parents=True)
+	_create_initial_commit(pygit2.init_repository(str(beta_dir), bare=False), beta_dir)
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "PROJECT_MAXDEPTH", 3),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "LIST_ALL", True),
+		patch.object(settings, "AUTH", None),
+		patch.object(settings, "MAXLOAD", None),
+	):
+		yield {"root": str(root), "alpha": "alpha", "beta": "group/beta"}
+
+
+def _client() -> httpx.AsyncClient:
+	return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
+
+
+class TestUpdatesLongPoll:
+	def test_updates_returns_304_after_timeout(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> int:
+			async with _client() as client:
+				with patch.object(CHANGE_QUEUE, "DEFAULT_TIMEOUT_SECONDS", 0.05):
+					resp = await client.get(
+						f"/project/{updates_env['alpha']}",
+						params={"a": "log", "updates": "true"},
+					)
+				return resp.status_code
+
+		assert asyncio.run(scenario()) == 304
+
+	def test_updates_returns_data_after_notify(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> tuple[int, str]:
+			async with _client() as client:
+				poll: asyncio.Task[httpx.Response] = asyncio.create_task(
+					client.get(
+						f"/project/{updates_env['alpha']}",
+						params={"a": "log", "updates": "true"},
+					)
+				)
+				await asyncio.sleep(0.05)
+				notify_resp = await client.post(
+					"/_internal/notify",
+					params={"project": updates_env["alpha"]},
+				)
+				assert notify_resp.status_code == 200
+				assert notify_resp.json()["waiters_woken"] == 1
+				resp: httpx.Response = await poll
+				return resp.status_code, resp.text
+
+		status, body = asyncio.run(scenario())
+		assert status == 200
+		assert "Log" in body
+
+	def test_updates_with_pf_wakes_on_any_matching_project(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> int:
+			async with _client() as client:
+				poll: asyncio.Task[httpx.Response] = asyncio.create_task(
+					client.get(
+						f"/project/{updates_env['alpha']}",
+						params={"a": "heads", "updates": "true", "pf": "group"},
+					)
+				)
+				await asyncio.sleep(0.05)
+				notify_resp = await client.post(
+					"/_internal/notify",
+					params={"project": updates_env["beta"]},
+				)
+				assert notify_resp.status_code == 200
+				assert notify_resp.json()["waiters_woken"] == 1
+				return (await poll).status_code
+
+		assert asyncio.run(scenario()) == 200
+
+	def test_updates_rejected_for_unsupported_action(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> int:
+			async with _client() as client:
+				resp = await client.get(
+					f"/project/{updates_env['alpha']}",
+					params={"a": "tree", "updates": "true"},
+				)
+				return resp.status_code
+
+		assert asyncio.run(scenario()) == 400
+
+	def test_notify_unknown_project_returns_404(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> int:
+			async with _client() as client:
+				resp = await client.post("/_internal/notify", params={"project": "no-such-project"})
+				return resp.status_code
+
+		assert asyncio.run(scenario()) == 404
diff --git a/pygitweb/change_queue_test.py b/pygitweb/change_queue_test.py
new file mode 100644
index 0000000..7cfed6e
--- /dev/null
+++ b/pygitweb/change_queue_test.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import asyncio
+
+from pygitweb.change_queue import ChangeQueue
+
+
+class TestChangeQueue:
+	def test_notify_wakes_waiter_with_project_name(self) -> None:
+		async def scenario() -> tuple[int, str | None, int]:
+			queue: ChangeQueue = ChangeQueue()
+			waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
+			await asyncio.sleep(0)
+			woken: int = await queue.notify("alpha")
+			result: str | None = await waiter
+			leftover: int = await queue.waiter_count()
+			return woken, result, leftover
+
+		woken, result, leftover = asyncio.run(scenario())
+		assert woken == 1
+		assert result == "alpha"
+		assert leftover == 0
+
+	def test_wait_returns_none_on_timeout(self) -> None:
+		async def scenario() -> tuple[str | None, int]:
+			queue: ChangeQueue = ChangeQueue()
+			outcome: str | None = await queue.wait_for_changes(["alpha"], timeout=0.05)
+			leftover: int = await queue.waiter_count("alpha")
+			return outcome, leftover
+
+		outcome, leftover = asyncio.run(scenario())
+		assert outcome is None
+		assert leftover == 0
+
+	def test_notify_only_wakes_subscribed_waiters(self) -> None:
+		async def scenario() -> tuple[int, str | None]:
+			queue: ChangeQueue = ChangeQueue()
+			waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=0.2))
+			await asyncio.sleep(0)
+			woken: int = await queue.notify("beta")
+			outcome: str | None = await waiter
+			return woken, outcome
+
+		woken, outcome = asyncio.run(scenario())
+		assert woken == 0
+		assert outcome is None
+
+	def test_wait_for_multiple_projects_wakes_on_any(self) -> None:
+		async def scenario() -> tuple[str | None, int]:
+			queue: ChangeQueue = ChangeQueue()
+			waiter: asyncio.Task[str | None] = asyncio.create_task(
+				queue.wait_for_changes(["alpha", "group/beta"], timeout=2.0)
+			)
+			await asyncio.sleep(0)
+			await queue.notify("group/beta")
+			outcome: str | None = await waiter
+			leftover: int = await queue.waiter_count()
+			return outcome, leftover
+
+		outcome, leftover = asyncio.run(scenario())
+		assert outcome == "group/beta"
+		assert leftover == 0
+
+	def test_notify_returns_zero_when_no_waiters(self) -> None:
+		async def scenario() -> int:
+			queue: ChangeQueue = ChangeQueue()
+			return await queue.notify("alpha")
+
+		assert asyncio.run(scenario()) == 0
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
new file mode 100644
index 0000000..2c2658e
--- /dev/null
+++ b/pygitweb/hooks_install.py
@@ -0,0 +1,210 @@
+"""
+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 os
+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"
+
+_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",
+}
+
+
+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]
+
+
+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 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)
diff --git a/pygitweb/hooks_install_test.py b/pygitweb/hooks_install_test.py
new file mode 100644
index 0000000..8f3051e
--- /dev/null
+++ b/pygitweb/hooks_install_test.py
@@ -0,0 +1,321 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi.testclient import TestClient
+
+from pygitweb.config import settings
+from pygitweb.hooks_install import (
+	HOOK_BUNDLES,
+	HookStatus,
+	bundle_status,
+	get_bundle,
+	get_sample,
+	install,
+	install_bundle,
+	is_installed,
+	list_bundles,
+	list_samples,
+	remove,
+	remove_bundle,
+	status,
+)
+from pygitweb.main import app
+
+
+def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
+	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+	index = repo.index
+	index.add("README.md")
+	index.write()
+	tree = index.write_tree()
+	sig = pygit2.Signature("tester", "tester@example.com")
+	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+
+
+@pytest.fixture
+def project_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
+	root: Path = tmp_path / "root"
+	root.mkdir()
+	repo_dir: Path = root / "demo"
+	repo_dir.mkdir()
+	_create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "PROJECT_MAXDEPTH", 3),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "LIST_ALL", True),
+		patch.object(settings, "AUTH", None),
+		patch.object(settings, "MAXLOAD", None),
+	):
+		yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}
+
+
+class TestRegistry:
+	def test_list_samples_includes_notify_pair(self) -> None:
+		names: set[str] = {s["name"] for s in list_samples()}
+		assert {"post-commit.notify", "post-receive.notify"} <= names
+
+	def test_get_sample_target_is_filename_prefix(self) -> None:
+		sample = get_sample("post-receive.notify")
+		assert sample is not None
+		assert sample["target"] == "post-receive"
+		assert Path(sample["source"]).is_file()
+
+	def test_get_sample_unknown_returns_none(self) -> None:
+		assert get_sample("does-not-exist.foo") is None
+
+	def test_update_bundle_includes_both_notify_samples(self) -> None:
+		bundle = get_bundle("update")
+		assert bundle is not None
+		assert bundle["label"] == "Update Hook"
+		assert {m["name"] for m in bundle["members"]} == {"post-commit.notify", "post-receive.notify"}
+
+	def test_list_bundles_returns_update(self) -> None:
+		assert any(b["name"] == "update" for b in list_bundles())
+		assert "update" in HOOK_BUNDLES
+
+
+class TestStatusInstallRemove:
+	def test_status_not_installed_when_no_file(self, project_env: dict[str, str]) -> None:
+		assert status(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
+		assert is_installed(project_env["project"], "post-receive.notify") is False
+
+	def test_install_then_status_installed(self, project_env: dict[str, str]) -> None:
+		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
+		assert is_installed(project_env["project"], "post-receive.notify") is True
+		target: Path = Path(project_env["hooks_dir"]) / "post-receive"
+		assert target.is_file()
+
+	def test_install_is_idempotent(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-receive.notify")
+		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
+
+	def test_install_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+		assert install(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
+		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
+
+	def test_remove_uninstalls_only_when_content_matches(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-receive.notify")
+		assert remove(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
+		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
+
+	def test_remove_preserves_custom_hook(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		custom: str = "#!/bin/sh\necho custom\n"
+		(hooks_dir / "post-receive").write_text(custom, encoding="utf-8")
+		assert remove(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
+		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == custom
+
+	def test_unknown_sample_raises(self, project_env: dict[str, str]) -> None:
+		with pytest.raises(KeyError):
+			status(project_env["project"], "no-such.sample")
+		with pytest.raises(KeyError):
+			install(project_env["project"], "no-such.sample")
+		with pytest.raises(KeyError):
+			remove(project_env["project"], "no-such.sample")
+
+
+class TestBundle:
+	def test_bundle_status_starts_not_installed(self, project_env: dict[str, str]) -> None:
+		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+
+	def test_install_bundle_installs_all_members(self, project_env: dict[str, str]) -> None:
+		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
+		assert is_installed(project_env["project"], "post-commit.notify")
+		assert is_installed(project_env["project"], "post-receive.notify")
+
+	def test_partial_install_reports_not_installed(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-commit.notify")
+		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+
+	def test_install_bundle_completes_partial(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-commit.notify")
+		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
+		assert is_installed(project_env["project"], "post-receive.notify")
+
+	def test_bundle_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+		assert bundle_status(project_env["project"], "update") == HookStatus.DIFFERENT
+		assert install_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
+		assert remove_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
+		assert (hooks_dir / "post-commit").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
+
+	def test_remove_bundle_uninstalls_only_managed_members(self, project_env: dict[str, str]) -> None:
+		install_bundle(project_env["project"], "update")
+		assert remove_bundle(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+		assert not (Path(project_env["hooks_dir"]) / "post-commit").exists()
+		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
+
+	def test_unknown_bundle_raises(self, project_env: dict[str, str]) -> None:
+		with pytest.raises(KeyError):
+			bundle_status(project_env["project"], "no-such-bundle")
+		with pytest.raises(KeyError):
+			install_bundle(project_env["project"], "no-such-bundle")
+		with pytest.raises(KeyError):
+			remove_bundle(project_env["project"], "no-such-bundle")
+
+
+def _client() -> TestClient:
+	return TestClient(app)
+
+
+class TestHookRoute:
+	def test_sample_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
+		project: str = project_env["project"]
+		with _client() as client:
+			check_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "post-receive.notify", "op": "check"},
+			)
+			assert check_resp.status_code == 200
+			body = check_resp.json()
+			assert body["installed"] is False
+			assert body["kind"] == "sample"
+
+			add_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "post-receive.notify", "op": "add"},
+			)
+			assert add_resp.status_code == 200
+			body = add_resp.json()
+			assert body["installed"] is True
+			assert body["target"] == "post-receive"
+			assert body["label"] == "Post-receive Notify"
+
+			remove_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "post-receive.notify", "op": "remove"},
+			)
+			assert remove_resp.status_code == 200
+			assert remove_resp.json()["installed"] is False
+
+	def test_bundle_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
+		project: str = project_env["project"]
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		with _client() as client:
+			check_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "update", "op": "check"},
+			)
+			assert check_resp.status_code == 200
+			body = check_resp.json()
+			assert body["installed"] is False
+			assert body["kind"] == "bundle"
+			assert body["label"] == "Update Hook"
+			assert set(body["members"]) == {"post-commit.notify", "post-receive.notify"}
+
+			add_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "update", "op": "add"},
+			)
+			assert add_resp.status_code == 200
+			assert add_resp.json()["installed"] is True
+			assert (hooks_dir / "post-commit").is_file()
+			assert (hooks_dir / "post-receive").is_file()
+
+			remove_resp = client.post(
+				f"/project/{project}/hook",
+				params={"name": "update", "op": "remove"},
+			)
+			assert remove_resp.status_code == 200
+			assert remove_resp.json()["installed"] is False
+			assert not (hooks_dir / "post-commit").exists()
+			assert not (hooks_dir / "post-receive").exists()
+
+	def test_bundle_add_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+		with _client() as client:
+			resp = client.post(
+				f"/project/{project_env['project']}/hook",
+				params={"name": "update", "op": "add"},
+			)
+		assert resp.status_code == 409
+
+	def test_invalid_op_returns_400(self, project_env: dict[str, str]) -> None:
+		with _client() as client:
+			resp = client.post(
+				f"/project/{project_env['project']}/hook",
+				params={"name": "post-receive.notify", "op": "noop"},
+			)
+		assert resp.status_code == 400
+
+	def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
+		with _client() as client:
+			resp = client.post(
+				f"/project/{project_env['project']}/hook",
+				params={"name": "no-such.thing", "op": "check"},
+			)
+		assert resp.status_code == 404
+
+	def test_add_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+		with _client() as client:
+			resp = client.post(
+				f"/project/{project_env['project']}/hook",
+				params={"name": "post-receive.notify", "op": "add"},
+			)
+		assert resp.status_code == 409
+
+	def test_hooks_list_endpoint_returns_status_for_samples_and_bundles(self, project_env: dict[str, str]) -> None:
+		with _client() as client:
+			resp = client.get(f"/project/{project_env['project']}/hooks")
+		assert resp.status_code == 200
+		body = resp.json()
+		assert body["project"] == project_env["project"]
+		sample_names: set[str] = {entry["name"] for entry in body["hooks"]}
+		assert {"post-commit.notify", "post-receive.notify"} <= sample_names
+		bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
+		assert "update" in bundle_names
+
+	def test_summary_renders_install_button_when_bundle_not_installed(self, project_env: dict[str, str]) -> None:
+		with _client() as client:
+			resp = client.get(f"/project/{project_env['project']}")
+		assert resp.status_code == 200
+		assert "Install Update Hook" in resp.text
+		assert 'data-sample="update"' in resp.text
+		assert "btn btn-sm btn-primary" in resp.text
+
+	def test_summary_renders_remove_button_when_bundle_fully_installed(self, project_env: dict[str, str]) -> None:
+		install_bundle(project_env["project"], "update")
+		with _client() as client:
+			resp = client.get(f"/project/{project_env['project']}")
+		assert resp.status_code == 200
+		assert "Remove Update Hook" in resp.text
+		assert "btn btn-sm btn-ghost-secondary" in resp.text
+
+	def test_summary_renders_install_button_when_only_one_member_installed(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-commit.notify")
+		with _client() as client:
+			resp = client.get(f"/project/{project_env['project']}")
+		assert resp.status_code == 200
+		assert "Install Update Hook" in resp.text
+
+
+class TestSummaryRefSwitcherStaticScript:
+	def test_static_script_subscribes_via_updates_endpoint(self) -> None:
+		with _client() as client:
+			resp = client.get("/static/summary-ref-switcher.js")
+		assert resp.status_code == 200
+		body: str = resp.text
+		assert "a=heads&updates=true" in body
+		assert "subscribeToUpdates" in body
+		assert "AbortController" in body
+		assert "loadOptions" in body
+		assert "loadState" in body
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 107c750..cac0667 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -14,7 +14,7 @@ from urllib.parse import quote
 
 import pygit2
 from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
-from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
+from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
 from fastapi.staticfiles import StaticFiles
 
 from pygitweb import __meta__
@@ -45,9 +45,33 @@ from pygitweb.actions import (
 from pygitweb.api.plugins.project_zip import ProjectZip
 from pygitweb.api.project import Project
 from pygitweb.api.subpage import Subpage
+from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import ACTIONS, get_loadavg, settings
 from pygitweb.formatting import age_string
 from pygitweb.git_helpers import git_get_references, git_get_type
+from pygitweb.hooks_install import (
+	HookStatus,
+	bundle_status,
+	get_bundle,
+	get_sample,
+	list_bundles,
+	list_samples,
+)
+from pygitweb.hooks_install import (
+	install as install_hook,
+)
+from pygitweb.hooks_install import (
+	install_bundle as install_hook_bundle,
+)
+from pygitweb.hooks_install import (
+	remove as remove_hook,
+)
+from pygitweb.hooks_install import (
+	remove_bundle as remove_hook_bundle,
+)
+from pygitweb.hooks_install import (
+	status as hook_status,
+)
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.settings import router as settings_router
 from pygitweb.tasks import (
@@ -67,6 +91,31 @@ except ImportError:
 	SubpagePytestHtml = None
 
 
+UPDATES_SUPPORTED_ACTIONS: frozenset[str] = frozenset({"history", "log", "shortlog", "heads", "tags"})
+
+
+def _parse_updates_flag(value: str | None) -> bool:
+	if value is None:
+		return False
+	return value.strip().lower() in ("1", "true", "yes", "on")
+
+
+def _resolve_subscribed_projects(project: str, project_filter: str | None) -> list[str]:
+	"""Pick the project set to long-poll. Uses pf prefix if provided, else the URL project."""
+	if not project_filter:
+		return [project]
+	pf = project_filter.strip().strip("/")
+	if not pf:
+		return [project]
+	matches = git_get_projects_list(
+		filter_path=pf,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
+	)
+	names: list[str] = [m.get("path", "") for m in matches if m.get("path")]
+	return names or [project]
+
+
 @asynccontextmanager
 async def timeline_cache_lifespan(app: FastAPI) -> None:
 	warm_timeline_cache_async()
@@ -559,8 +608,143 @@ def project_subpage(
 	raise HTTPException(status_code=404, detail="Unknown subpage")
 
 
+@app.post("/project/{project:path}/hook", response_class=JSONResponse)
+def project_hook(
+	request: Request,
+	project: str,
+	name: Annotated[str, Query(alias="name")],
+	op: Annotated[str, Query(alias="op")] = "check",
+) -> JSONResponse:
+	"""Add, remove, or check a pygittools hook sample (or bundle of samples) for a project.
+
+	`name` may be a sample filename (e.g. `post-receive.notify`) or a bundle name
+	(e.g. `update`, which installs both `post-commit.notify` and `post-receive.notify`).
+	`op` is `add`, `remove`, or `check`.
+	"""
+	_validate_project(project)
+	if op != "check" and not _request_can_add_project(request):
+		raise HTTPException(status_code=401, detail="Authentication required to manage hooks")
+	if op not in ("add", "remove", "check"):
+		raise HTTPException(status_code=400, detail="op must be one of: add, remove, check")
+	bundle = get_bundle(name)
+	sample = get_sample(name) if bundle is None else None
+	if bundle is None and sample is None:
+		raise HTTPException(status_code=404, detail=f"Unknown hook sample or bundle: {name}")
+	try:
+		if bundle is not None:
+			if op == "add":
+				result_status: HookStatus = install_hook_bundle(project, name)
+				if result_status == HookStatus.DIFFERENT:
+					raise HTTPException(
+						status_code=409,
+						detail="One or more custom hooks installed; refusing to add",
+					)
+			elif op == "remove":
+				result_status = remove_hook_bundle(project, name)
+				if result_status == HookStatus.DIFFERENT:
+					raise HTTPException(
+						status_code=409,
+						detail="One or more custom hooks installed; refusing to remove",
+					)
+			else:
+				result_status = bundle_status(project, name)
+		else:
+			assert sample is not None
+			if op == "add":
+				result_status = install_hook(project, name)
+				if result_status == HookStatus.DIFFERENT:
+					raise HTTPException(
+						status_code=409,
+						detail="A different hook is already installed at this path; refusing to overwrite",
+					)
+			elif op == "remove":
+				result_status = remove_hook(project, name)
+				if result_status == HookStatus.DIFFERENT:
+					raise HTTPException(
+						status_code=409,
+						detail="Installed hook content differs from the sample; refusing to remove",
+					)
+			else:
+				result_status = hook_status(project, name)
+	except OSError as e:
+		raise HTTPException(status_code=500, detail=str(e)) from e
+	if bundle is not None:
+		body: dict[str, object] = {
+			"project": project,
+			"kind": "bundle",
+			"name": name,
+			"label": bundle["label"],
+			"members": [m["name"] for m in bundle["members"]],
+			"op": op,
+			"status": result_status.value,
+			"installed": result_status == HookStatus.INSTALLED,
+		}
+	else:
+		assert sample is not None
+		body = {
+			"project": project,
+			"kind": "sample",
+			"name": name,
+			"target": sample["target"],
+			"label": sample["label"],
+			"op": op,
+			"status": result_status.value,
+			"installed": result_status == HookStatus.INSTALLED,
+		}
+	return JSONResponse(body)
+
+
+@app.get("/project/{project:path}/hooks", response_class=JSONResponse)
+def project_hooks_list(project: str) -> JSONResponse:
+	"""List all hook samples and bundles with current status in this project's hooks dir."""
+	_validate_project(project)
+	samples_out: list[dict[str, object]] = []
+	for sample in list_samples():
+		try:
+			st: HookStatus = hook_status(project, sample["name"])
+		except KeyError:
+			continue
+		samples_out.append({
+			"name": sample["name"],
+			"target": sample["target"],
+			"label": sample["label"],
+			"status": st.value,
+			"installed": st == HookStatus.INSTALLED,
+		})
+	bundles_out: list[dict[str, object]] = []
+	for bundle in list_bundles():
+		try:
+			bst: HookStatus = bundle_status(project, bundle["name"])
+		except KeyError:
+			continue
+		bundles_out.append({
+			"name": bundle["name"],
+			"label": bundle["label"],
+			"members": [m["name"] for m in bundle["members"]],
+			"status": bst.value,
+			"installed": bst == HookStatus.INSTALLED,
+		})
+	return JSONResponse({"project": project, "hooks": samples_out, "bundles": bundles_out})
+
+
+@app.post("/_internal/notify", response_class=JSONResponse)
+async def internal_notify(
+	project: Annotated[str | None, Query(alias="project")] = None,
+) -> JSONResponse:
+	"""Notify long-polling subscribers that a project's refs changed.
+
+	Intended for server-side hooks (pre-receive / post-receive) running on the same host.
+	"""
+	if not project:
+		raise HTTPException(status_code=400, detail="project query parameter required")
+	if not _project_in_list(project):
+		raise HTTPException(status_code=404, detail="No such project")
+	woken = await CHANGE_QUEUE.notify(project)
+	return JSONResponse({"project": project, "waiters_woken": woken})
+
+
 @app.get("/project/{project:path}", response_class=HTMLResponse)
-def dispatch(
+async def dispatch(
 	request: Request,
 	project: str,
 	a: Annotated[str | None, Query(alias="a")] = None,
@@ -570,6 +754,8 @@ def dispatch(
 	fp: Annotated[str | None, Query(alias="fp")] = None,
 	page: Annotated[str | None, Query(alias="page")] = None,
 	pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
+	updates: Annotated[str | None, Query(alias="updates")] = None,
+	pf: Annotated[str | None, Query(alias="pf")] = None,
 ):
 	"""
 	Dispatch by path: /project/{project} -> summary; /project/{project}/action/... -> action.
@@ -605,7 +791,13 @@ def dispatch(
 		raise HTTPException(status_code=400, detail="Unknown action")
 	if action in ("opml", "project_list", "project_index"):
 		raise HTTPException(status_code=400, detail="Project not needed for this action")
-	# Route to handler
+	if _parse_updates_flag(updates):
+		if action not in UPDATES_SUPPORTED_ACTIONS:
+			raise HTTPException(status_code=400, detail="updates not supported for this action")
+		subscribed = _resolve_subscribed_projects(project, pf)
+		notified = await CHANGE_QUEUE.wait_for_changes(subscribed, timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS)
+		if notified is None:
+			return Response(status_code=304)
 	if action == "summary":
 		return git_summary(project, extra_rows=_project_subpage_rows(project))
 	if action == "tree":
diff --git a/pygitweb/static/hook-install.js b/pygitweb/static/hook-install.js
new file mode 100644
index 0000000..1091aff
--- /dev/null
+++ b/pygitweb/static/hook-install.js
@@ -0,0 +1,35 @@
+(() => {
+	const handleClick = async (event) => {
+		const btn = event.target.closest(".hook-toggle");
+		if (!btn) {
+			return;
+		}
+		event.preventDefault();
+		const project = btn.dataset.project;
+		const sample = btn.dataset.sample;
+		const op = btn.dataset.op;
+		if (!project || !sample || !op) {
+			return;
+		}
+		btn.disabled = true;
+		const params = new URLSearchParams({ name: sample, op });
+		try {
+			const resp = await fetch(`/project/${project}/hook?${params.toString()}`, {
+				method: "POST",
+				headers: { Accept: "application/json" },
+			});
+			if (!resp.ok) {
+				const detail = await resp.text();
+				window.alert(`Hook ${op} failed (${resp.status}): ${detail}`);
+				btn.disabled = false;
+				return;
+			}
+			window.location.reload();
+		} catch (err) {
+			window.alert(`Hook ${op} failed: ${err}`);
+			btn.disabled = false;
+		}
+	};
+
+	document.addEventListener("click", handleClick);
+})();
diff --git a/pygitweb/static/summary-ref-switcher.js b/pygitweb/static/summary-ref-switcher.js
index 9eb9f82..2f4ad5e 100644
--- a/pygitweb/static/summary-ref-switcher.js
+++ b/pygitweb/static/summary-ref-switcher.js
@@ -17,6 +17,8 @@
 	const projectApiBase = () =>
 		"/project/" + project.split("/").map((segment) => encodeURIComponent(segment)).join("/");
 
+	const SUBSCRIBE_BACKOFF_MS = 5000;
+
 	const rerunScripts = () => {
 		for (const oldScript of readmeContainerEl.querySelectorAll("script")) {
 			const scriptEl = document.createElement("script");
@@ -52,22 +54,66 @@
 		}
 	};
 
-	const loadOptions = async () => {
-		const response = await fetch(`${projectApiBase()}/summary-refs`);
-		if (!response.ok) {
-			return;
+	const populateOptions = (options) => {
+		const previousValue = selectEl.value;
+		for (const opt of Array.from(selectEl.options)) {
+			if (opt.value !== "HEAD") {
+				opt.remove();
+			}
 		}
-		const data = await response.json();
-		for (const optionData of data.options || []) {
+		for (const optionData of options || []) {
 			const optionEl = document.createElement("option");
 			optionEl.value = optionData.value;
 			optionEl.textContent = `${optionData.kind}: ${optionData.label}`;
 			selectEl.appendChild(optionEl);
 		}
+		const stillPresent = Array.from(selectEl.options).some((opt) => opt.value === previousValue);
+		selectEl.value = stillPresent ? previousValue : "HEAD";
+		return selectEl.value;
+	};
+
+	const loadOptions = async () => {
+		const response = await fetch(`${projectApiBase()}/summary-refs`);
+		if (!response.ok) {
+			return null;
+		}
+		const data = await response.json();
+		return populateOptions(data.options);
 	};
 
 	selectEl.addEventListener("change", () => {
 		void loadState(selectEl.value);
 	});
+
+	const subscriptionController = new AbortController();
+	window.addEventListener("beforeunload", () => subscriptionController.abort());
+
+	const subscribeToUpdates = async () => {
+		while (!subscriptionController.signal.aborted) {
+			let resp;
+			try {
+				resp = await fetch(`${projectApiBase()}?a=heads&updates=true`, {
+					signal: subscriptionController.signal,
+				});
+			} catch (err) {
+				if (subscriptionController.signal.aborted) {
+					return;
+				}
+				await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_BACKOFF_MS));
+				continue;
+			}
+			if (subscriptionController.signal.aborted) {
+				return;
+			}
+			if (resp.status === 200) {
+				const effectiveRef = await loadOptions();
+				await loadState(effectiveRef ?? selectEl.value);
+			} else if (resp.status !== 304) {
+				await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_BACKOFF_MS));
+			}
+		}
+	};
+
 	void loadOptions();
+	void subscribeToUpdates();
 })();
diff --git a/pygitweb/templates/update_hook_cell.html b/pygitweb/templates/update_hook_cell.html
new file mode 100644
index 0000000..666dc82
--- /dev/null
+++ b/pygitweb/templates/update_hook_cell.html
@@ -0,0 +1,9 @@
+{# Update Hook summary cell: status + install/remove toggle (hook-install.js). Expects mode: unavailable | different | toggle. #}
+{% if mode == "unavailable" %}
+<span class="text-muted">unavailable</span>
+{% elif mode == "different" %}
+<span class="text-muted">A custom hook is already installed at one of <code>post-commit</code> / <code>post-receive</code>; refusing to manage it.</span>
+{% elif mode == "toggle" %}
+<span class="hook-state text-muted me-2" data-sample="{{ bundle }}">{{ state_label }}</span>
+<button type="button" class="{{ button_class }}" data-project="{{ project_enc }}" data-sample="{{ bundle }}" data-op="{{ next_op }}">{{ button_label }}</button>
+{% endif %}
