diff --git a/pygittools/src/pygittools/hooks_notify.py b/pygittools/src/pygittools/hooks_notify.py
index 6ae1093..eee53ed 100644
--- a/pygittools/src/pygittools/hooks_notify.py
+++ b/pygittools/src/pygittools/hooks_notify.py
@@ -22,7 +22,7 @@ def _post_notify(notify_url: str, project: str, timeout: float) -> bool:
 	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
+			return 200 <= int(resp.status) < 300
 	except (urllib.error.URLError, OSError, ValueError) as exc:
 		stderr.write(f"notify {url!r} failed: {exc}\n")
 		return False
diff --git a/pygittools/src/pygittools/tui/tasks_list.py b/pygittools/src/pygittools/tui/tasks_list.py
index 6e68d98..1ba9710 100644
--- a/pygittools/src/pygittools/tui/tasks_list.py
+++ b/pygittools/src/pygittools/tui/tasks_list.py
@@ -3,10 +3,10 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
 
 from pygit2 import Repository
-from pyratatui import Frame, List, ListItem, ListState, Rect
+from pyratatui import Frame, List, ListItem, ListState, Rect, Style
 
 from pygittools.tags_sync import format_sync_result, sync_board_tags
 from pygittools.tasks_query import (
@@ -20,6 +20,9 @@ from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.keys import KeyLike, board_hint, is_down, is_enter, is_left, is_right, is_space, is_up, move_hint
 from pygittools.tui.types import PageAction, PageResult
 
+if TYPE_CHECKING:
+	from pygittools.tui.pages.task_editor import TaskEditorPage
+
 
 @dataclass(frozen=True, slots=True)
 class _Row:
@@ -256,7 +259,7 @@ def _board_index_for_name(boards: list[BoardView], name: str | None) -> int:
 	return _default_board_index(boards)
 
 
-def _row_style(row: _Row, *, focused: bool, highlight: bool):
+def _row_style(row: _Row, *, focused: bool, highlight: bool) -> Style:
 	theme = current_theme()
 	if row.kind == "board":
 		base = theme.header
@@ -269,7 +272,7 @@ def _row_style(row: _Row, *, focused: bool, highlight: bool):
 	return base
 
 
-def _task_editor_page(board_name: str, task_ref: str | None, *, default_status: str | None):
+def _task_editor_page(board_name: str, task_ref: str | None, *, default_status: str | None) -> TaskEditorPage:
 	from pygittools.tui.pages.task_editor import TaskEditorPage
 
 	return TaskEditorPage(board_name=board_name, task_ref=task_ref, default_status=default_status)
diff --git a/pygitweb/src/pygitweb/api/subpage.py b/pygitweb/src/pygitweb/api/subpage.py
index 0991ec5..8c6850a 100644
--- a/pygitweb/src/pygitweb/api/subpage.py
+++ b/pygitweb/src/pygitweb/api/subpage.py
@@ -38,7 +38,8 @@ class _SubpageMeta(AnnotatedProtocolMeta):
 
 	def register(cls, subclass: type) -> type:
 		_install_default_methods(cls, subclass)
-		return super().register(subclass)
+		super().register(subclass)
+		return subclass
 
 
 @runtime_checkable
diff --git a/pygitweb/src/pygitweb/git_helpers.py b/pygitweb/src/pygitweb/git_helpers.py
index fc32bde..e8c00b0 100644
--- a/pygitweb/src/pygitweb/git_helpers.py
+++ b/pygitweb/src/pygitweb/git_helpers.py
@@ -30,7 +30,7 @@ def _repo_path(project: str) -> str:
 	return os.path.join(settings.PROJECTROOT, project)
 
 
-def open_repo(project: str):
+def open_repo(project: str) -> pygit2.Repository:
 	"""Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
 	path = _repo_path(project)
 	return pygit2.Repository(path)
@@ -101,27 +101,30 @@ def git_get_type(project: str, ref: str) -> str | None:
 		return None
 
 
-def hash_set_multi(d: dict[str, Any], key: str, value: Any) -> None:
+def hash_set_multi(d: dict[str, str | list[str]], key: str, value: str) -> None:
 	"""Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
-	if key not in d:
+	existing = d.get(key)
+	if existing is None:
 		d[key] = value
-	elif not isinstance(d[key], list):
-		d[key] = [d[key], value]
+	elif isinstance(existing, list):
+		existing.append(value)
 	else:
-		d[key].append(value)
+		d[key] = [existing, value]
 
 
-def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, Any]:
+def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, str | list[str]]:
 	"""All config key/values; optionally filter by section. Port of git_parse_project_config."""
 	try:
 		repo = open_repo(project)
 		cfg = repo.config
-		result: dict[str, Any] = {}
+		result: dict[str, str | list[str]] = {}
 		for entry in cfg:
 			key = entry.name
 			if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
 				continue
 			value = entry.value
+			if value is None:
+				continue
 			hash_set_multi(result, key, value)
 		return result
 	except (pygit2.GitError, OSError):
@@ -152,7 +155,7 @@ def config_to_int(val: str | None) -> int | str:
 
 
 # Per-repo cache for gitweb config (git_parse_project_config result)
-_config_cache: dict[str, tuple[str, dict[str, Any]]] = {}
+_config_cache: dict[str, tuple[str, dict[str, str | list[str]]]] = {}
 
 
 def git_get_project_config(
@@ -290,7 +293,7 @@ def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
 	try:
 		repo = open_repo(project)
 		result = []
-		for ref in repo.references.iterator(pygit2.GIT_REFERENCES_TAGS):
+		for ref in repo.references.iterator(pygit2.enums.ReferenceFilter.TAGS):
 			oid = str(ref.resolve().target)
 			name = ref.name.replace("refs/tags/", "")
 			ts = _tag_timestamp(repo, oid)
@@ -307,6 +310,8 @@ def git_get_remotes_info(project: str) -> list[dict[str, Any]]:
 		repo = open_repo(project)
 		result = []
 		for name in repo.remotes.names():
+			if not name:
+				continue
 			remote = repo.remotes[name]
 			result.append({
 				"name": name,
@@ -382,17 +387,23 @@ def get_commit_history(
 		repo = open_repo(project)
 		if ref:
 			try:
-				start_oid = repo.revparse_single(ref).peel(pygit2.Commit).id
+				start_oid: pygit2.Oid | None = repo.revparse_single(ref).peel(pygit2.Commit).id
 			except (KeyError, pygit2.GitError, ValueError):
 				start_oid = None
 		else:
-			start_oid = repo.head.target if repo.head else None
+			try:
+				start_oid = repo.head.peel(pygit2.Commit).id if repo.head else None
+			except (KeyError, pygit2.GitError, ValueError):
+				start_oid = None
 		if not start_oid:
 			return []
 
 		commits: list[dict[str, Any]] = []
 		skipped = 0
-		walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
+		walker = repo.walk(
+			start_oid,
+			pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
+		)
 
 		if path:
 			# Filter by path: only commits that touched this path (or a file under it)
@@ -453,7 +464,10 @@ def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
 	try:
 		repo = open_repo(project)
 		tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
-		walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
+		walker = repo.walk(
+			tip_commit.id,
+			pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
+		)
 		if base:
 			with suppress(KeyError, pygit2.GitError, ValueError):
 				walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
diff --git a/pygitweb/src/pygitweb/main.py b/pygitweb/src/pygitweb/main.py
index 238544f..c4ad8ae 100644
--- a/pygitweb/src/pygitweb/main.py
+++ b/pygitweb/src/pygitweb/main.py
@@ -7,7 +7,7 @@ from __future__ import annotations
 
 import asyncio
 import os
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Awaitable, Callable
 from contextlib import asynccontextmanager
 from datetime import UTC, datetime
 from pathlib import Path
@@ -223,7 +223,10 @@ app.include_router(http_router)
 
 
 @app.middleware("http")
-async def loadavg_middleware(request: Request, call_next):
+async def loadavg_middleware(
+	request: Request,
+	call_next: Callable[[Request], Awaitable[Response]],
+) -> Response:
 	try:
 		if settings.MAXLOAD is not None and get_loadavg() > settings.MAXLOAD:
 			raise RuntimeError("503:The load average on the server is too high")
@@ -519,7 +522,7 @@ _OPTIONAL_REPO_ZIP = File(default=None)
 def addproject_namevalid(
 	_: Annotated[None, Depends(require_permission(Permission.ADD_PROJECTS))],
 	name: Annotated[str | None, Query()] = None,
-):
+) -> HTMLResponse:
 	"""Check if project name is valid (requires pgw.addprojects when auth is enabled)."""
 	if not name:
 		raise HTTPException(status_code=400, detail="Param 'name' required")
@@ -875,7 +878,7 @@ async def dispatch(
 	search_sort: Annotated[SearchSortQuery | None, Query(alias="sort")] = None,
 	search_max_count: Annotated[str | None, Query(alias="max_count")] = None,
 	notice: Annotated[str | None, Query(alias="notice")] = None,
-):
+) -> Response:
 	"""
 	Dispatch by path: /project/{project} -> summary; /project/{project}/action/... -> action.
 	Port of dispatch + run_request path handling.
diff --git a/pygitweb/src/pygitweb/projects.py b/pygitweb/src/pygitweb/projects.py
index d934f34..c2d0061 100644
--- a/pygitweb/src/pygitweb/projects.py
+++ b/pygitweb/src/pygitweb/projects.py
@@ -154,7 +154,7 @@ def git_get_project_list_from_file(
 def git_get_project_owner(
 	project: str,
 	projectroot: str | None = None,
-	get_project_config: Callable[[str, str], Any] | None = None,
+	get_project_config: Callable[[str, str], int | str | list[str] | bool | None] | None = None,
 ) -> str | None:
 	"""Owner from list file or config or file ownership. Port of git_get_project_owner."""
 	if not project:
@@ -165,8 +165,10 @@ def git_get_project_owner(
 		return owners[project]
 	if get_project_config:
 		val = get_project_config(project, "owner")
-		if val:
-			return val[0] if isinstance(val, list) else val
+		if isinstance(val, list) and val:
+			return val[0]
+		if isinstance(val, str) and val:
+			return val
 	return None
 
 
diff --git a/pygitweb/src/pygitweb/settings.py b/pygitweb/src/pygitweb/settings.py
index 2c5e3f7..4ef37df 100644
--- a/pygitweb/src/pygitweb/settings.py
+++ b/pygitweb/src/pygitweb/settings.py
@@ -300,7 +300,7 @@ def _project_config_table_row(key: str, value: str, index: int) -> list[Cell]:
 
 # ---------- Routes: PyGitWeb ----------
 @router.get("/pygitweb", response_class=HTMLResponse)
-def settings_pygitweb_page(request: Request):
+def settings_pygitweb_page(request: Request) -> HTMLResponse:
 	"""PyGitWeb global settings form."""
 	fields_html = []
 	for name, val, value_type, doc in _get_pygitweb_values():
@@ -318,7 +318,7 @@ def settings_pygitweb_page(request: Request):
 
 
 @router.post("/pygitweb/submit", response_class=HTMLResponse)
-async def settings_pygitweb_submit(request: Request):
+async def settings_pygitweb_submit(request: Request) -> RedirectResponse:
 	"""Apply PyGitWeb settings and persist to the settings JSON file."""
 	form = await request.form()
 
@@ -344,7 +344,7 @@ async def settings_pygitweb_submit(request: Request):
 
 
 @router.post("/pygitweb/plugins", response_class=HTMLResponse)
-async def settings_pygitweb_plugins(request: Request):
+async def settings_pygitweb_plugins(request: Request) -> RedirectResponse:
 	"""Enable/disable installed plugins and reload the runtime registry immediately."""
 	form = await request.form()
 	available = set(list_available_plugins())
@@ -368,7 +368,7 @@ async def settings_pygitweb_shutdown(request: Request, background_tasks: Backgro
 
 # ---------- Routes: PyGit2 ----------
 @router.get("/pygit2", response_class=HTMLResponse)
-def settings_pygit2_page(request: Request):
+def settings_pygit2_page(request: Request) -> HTMLResponse:
 	"""PyGit2 library settings form."""
 	fields_html = []
 	for name, val, kind, doc in _get_pygit2_values():
@@ -383,7 +383,7 @@ def settings_pygit2_page(request: Request):
 
 
 @router.post("/pygit2/submit", response_class=HTMLResponse)
-async def settings_pygit2_submit(request: Request):
+async def settings_pygit2_submit(request: Request) -> RedirectResponse:
 	"""Apply PyGit2 settings from form (all pygit2_* fields)."""
 	form = await request.form()
 	st = pygit2.Settings
@@ -413,7 +413,7 @@ async def settings_pygit2_submit(request: Request):
 
 # ---------- Routes: Project ----------
 @router.get("/project/{name:path}", response_class=HTMLResponse)
-def settings_project_page(request: Request, name: ValidatedReadableSettingsProject):
+def settings_project_page(request: Request, name: ValidatedReadableSettingsProject) -> HTMLResponse:
 	"""Project-specific git config form (local/worktree only)."""
 	entries = _get_project_config_entries(name)
 	rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
@@ -433,7 +433,7 @@ def settings_project_page(request: Request, name: ValidatedReadableSettingsProje
 
 
 @router.post("/project/{name:path}/submit", response_class=HTMLResponse)
-async def settings_project_submit(request: Request, name: ValidatedWritableSettingsProject):
+async def settings_project_submit(request: Request, name: ValidatedWritableSettingsProject) -> RedirectResponse:
 	"""Apply project config from form."""
 	form = await request.form()
 	# Collect key/value pairs; keys are config_key_0, config_value_0, ...
diff --git a/pygitweb/src/pygitweb/settings_config.py b/pygitweb/src/pygitweb/settings_config.py
index d4c0714..74c9725 100644
--- a/pygitweb/src/pygitweb/settings_config.py
+++ b/pygitweb/src/pygitweb/settings_config.py
@@ -10,6 +10,7 @@ from __future__ import annotations
 
 import json
 import sys
+from collections.abc import Iterator
 from contextlib import contextmanager
 from pathlib import Path
 from typing import TYPE_CHECKING
@@ -109,7 +110,7 @@ def init_settings() -> Settings:
 
 
 @contextmanager
-def settings_batch_update(settings: Settings):
+def settings_batch_update(settings: Settings) -> Iterator[None]:
 	"""Apply multiple setting changes and persist once."""
 	global _batch_update
 
diff --git a/pygitweb/src/pygitweb/shutdown.py b/pygitweb/src/pygitweb/shutdown.py
index 5b63fab..9cd30cb 100644
--- a/pygitweb/src/pygitweb/shutdown.py
+++ b/pygitweb/src/pygitweb/shutdown.py
@@ -48,7 +48,7 @@ def install_graceful_shutdown_wakeup(app: FastAPI) -> bool:
 		) -> Callable[[int, FrameType | None], Any]:
 			def handler(signum: int, frame: FrameType | None) -> None:
 				begin_shutdown(app)
-				if callable(previous) and previous not in (signal.SIG_DFL, signal.SIG_IGN):
+				if callable(previous):
 					previous(signum, frame)
 
 			return handler
diff --git a/pygitweb/src/pygitweb/static_tests/conftest.py b/pygitweb/src/pygitweb/static_tests/conftest.py
index e5f1db7..676c576 100644
--- a/pygitweb/src/pygitweb/static_tests/conftest.py
+++ b/pygitweb/src/pygitweb/static_tests/conftest.py
@@ -38,7 +38,7 @@ def pytest_collect_file(file_path: Path, parent: pytest.Collector) -> object:
 @pytest.fixture(scope="session")
 def _js_browser() -> Iterator[tuple[Any, str]]:
 	pytest.importorskip("playwright.sync_api")
-	from playwright.sync_api import sync_playwright  # type: ignore[import-not-found]
+	from playwright.sync_api import sync_playwright
 
 	server = StaticServer()
 	server.start()
diff --git a/pyproject.toml b/pyproject.toml
index 05b00b3..196bdcf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,16 @@ markers = [
 ]
 
 [tool.mypy]
+python_version = "3.12"
+warn_unused_configs = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+check_untyped_defs = true
+warn_return_any = true
+strict_equality = true
+disallow_incomplete_defs = true
+disallow_untyped_decorators = true
+disallow_any_generics = true
 exclude = [
     ".*_test\\.py",
     # scripts/pytest.py would otherwise shadow the real pytest package
