diff --git a/pygitweb/formatting_test.py b/pygitweb/formatting_test.py
new file mode 100644
index 0000000..4c5cf6b
--- /dev/null
+++ b/pygitweb/formatting_test.py
@@ -0,0 +1,68 @@
+import pygitweb.formatting as formatting
+
+
+def test_to_utf8_returns_string_input_unchanged() -> None:
+	assert formatting.to_utf8("hello") == "hello"
+
+
+def test_to_utf8_decodes_utf8_bytes_successfully() -> None:
+	assert formatting.to_utf8("cafe".encode("utf-8")) == "cafe"  # noqa: UP012
+
+
+def test_to_utf8_uses_fallback_encoding_when_utf8_fails() -> None:
+	assert formatting.to_utf8(b"\xf1") == "ñ"
+
+
+def test_to_utf8_fallback_decode_uses_replace_for_invalid_bytes() -> None:
+	original: str = formatting.FALLBACK_ENCODING
+	formatting.FALLBACK_ENCODING = "ascii"
+	try:
+		assert formatting.to_utf8(b"\xffabc") == "\ufffdabc"
+	finally:
+		formatting.FALLBACK_ENCODING = original
+
+
+def test_esc_param_encodes_space_and_slash() -> None:
+	assert formatting.esc_param("a b/c") == "a+b%2Fc"
+
+
+def test_esc_path_info_escapes_question_mark() -> None:
+	assert formatting.esc_path_info("dir/file?.txt") == "dir/file%3F.txt"
+
+
+def test_esc_url_keeps_url_safe_characters() -> None:
+	assert formatting.esc_url("https://example.com/a b") == "https://example.com/a%20b"
+
+
+def test_quot_cec_formats_html_and_plain() -> None:
+	assert formatting.quot_cec("\n") == '<span class="cntrl">\\n</span>'
+	assert formatting.quot_cec("\n", nohtml=True) == "\\n"
+
+
+def test_quot_upr_formats_html_and_plain() -> None:
+	assert formatting.quot_upr("\x01") == '<span class="cntrl">&#9217;</span>'
+	assert formatting.quot_upr("\x01", nohtml=True) == "&#9217;"
+
+
+def test_esc_path_escapes_html_and_control_chars() -> None:
+	assert formatting.esc_path("a<\n") == 'a&lt;<span class="cntrl">\\n</span>'
+
+
+def test_esc_path_nbsp_replaces_spaces() -> None:
+	assert formatting.esc_path("a b", nbsp=True) == "a&nbsp;b"
+
+
+def test_sanitize_preserves_whitespace_controls_and_escapes_others() -> None:
+	assert formatting.sanitize("x\t\n\r\x01") == "x\t\n\r\\01"
+
+
+def test_unquote_unescapes_quoted_sequences() -> None:
+	assert formatting.unquote('"a\\tb\\040c"') == "a\tb c"
+
+
+def test_unquote_returns_input_when_not_wrapped_in_quotes() -> None:
+	assert formatting.unquote("plain\\ntext") == "plain\\ntext"
+
+
+def test_untabify_expands_tabs_to_next_tab_stop() -> None:
+	assert formatting.untabify("a\tb", tabwidth=4) == "a   b"
diff --git a/pygitweb/git_helpers_test.py b/pygitweb/git_helpers_test.py
new file mode 100644
index 0000000..6756096
--- /dev/null
+++ b/pygitweb/git_helpers_test.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import subprocess
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+
+from pygitweb.config import settings
+from pygitweb.git_helpers import (
+	_config_cache,
+	config_to_bool,
+	config_to_int,
+	get_blob_at_ref_path,
+	get_blob_unified_diff,
+	get_commit_history,
+	get_commit_unified_diff,
+	get_commits_in_range,
+	get_readme_at_ref_path,
+	get_tree_at_ref_path,
+	git_get_file_or_project_config,
+	git_get_heads_list,
+	git_get_project_config,
+	git_get_project_description,
+	git_get_references,
+	git_get_remotes_info,
+	git_get_tags_list,
+	git_get_type,
+	git_parse_project_config,
+	hash_set_multi,
+	parse_commit,
+	parse_tag,
+)
+
+
+def _run_git(repo_dir: Path, *args: str) -> str:
+	completed: subprocess.CompletedProcess[str] = subprocess.run(
+		["git", *args],
+		cwd=repo_dir,
+		capture_output=True,
+		text=True,
+		check=True,
+	)
+	return completed.stdout.strip()
+
+
+class TestGitHelpers:
+	class TestPure:
+		def test_hash_set_multi_promotes_to_list(self) -> None:
+			data: dict[str, object] = {}
+			hash_set_multi(data, "k", "v1")
+			hash_set_multi(data, "k", "v2")
+			assert data["k"] == ["v1", "v2"]
+
+		@pytest.mark.parametrize(
+			("value", "expected"),
+			[("true", True), ("0", False), (None, True)],
+		)
+		def test_config_to_bool(self, value: str | None, expected: bool) -> None:
+			assert config_to_bool(value) is expected
+
+		@pytest.mark.parametrize(
+			("value", "expected"),
+			[("2k", 2048), ("3M", 3145728), ("123", "123"), (None, 0)],
+		)
+		def test_config_to_int(self, value: str | None, expected: int | str) -> None:
+			assert config_to_int(value) == expected
+
+	class TestRepoReadonly:
+		@pytest.fixture(scope="class")
+		def repo_ctx(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+			project_root: Path = tmp_path_factory.mktemp("git-helpers")
+			repo_dir: Path = project_root / "sample"
+			repo_dir.mkdir()
+
+			_run_git(repo_dir, "init", "-b", "main")
+			_run_git(repo_dir, "config", "user.name", "Alice")
+			_run_git(repo_dir, "config", "user.email", "alice@example.com")
+			_run_git(repo_dir, "remote", "add", "origin", "https://example.com/repo.git")
+			_run_git(repo_dir, "remote", "set-url", "--push", "origin", "ssh://example.com/repo.git")
+			_run_git(repo_dir, "config", "gitweb.owner", "alice")
+			_run_git(repo_dir, "config", "--add", "gitweb.category", "infra")
+			_run_git(repo_dir, "config", "--add", "gitweb.category", "tools")
+
+			(repo_dir / "README.md").write_text("Root README\n", encoding="utf-8")
+			(repo_dir / "docs").mkdir()
+			(repo_dir / "docs" / "README").write_text("Docs README\n", encoding="utf-8")
+			(repo_dir / "docs" / "guide.txt").write_text("hello\n", encoding="utf-8")
+			(repo_dir / "src").mkdir()
+			(repo_dir / "src" / "app.py").write_text("print('v1')\n", encoding="utf-8")
+			_run_git(repo_dir, "add", ".")
+			_run_git(repo_dir, "commit", "-m", "initial commit")
+
+			first_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
+			(repo_dir / "docs" / "guide.txt").write_text("hello\nworld\n", encoding="utf-8")
+			(repo_dir / "src" / "app.py").write_text("print('v2')\n", encoding="utf-8")
+			_run_git(repo_dir, "add", ".")
+			_run_git(repo_dir, "commit", "-m", "update docs and app")
+			second_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
+
+			_run_git(
+				repo_dir,
+				"tag",
+				"-a",
+				"v1.0",
+				"-m",
+				"release v1.0",
+				second_commit,
+			)
+			_run_git(repo_dir, "tag", "lightweight", first_commit)
+			_run_git(repo_dir, "branch", "feature/readme", second_commit)
+
+			git_dir: Path = repo_dir / ".git"
+			(git_dir / "description").write_text("Repo description\n", encoding="utf-8")
+
+			ctx: dict[str, str] = {
+				"project": "sample/.git",
+				"first_commit": first_commit,
+				"second_commit": second_commit,
+			}
+
+			_config_cache.clear()
+			with patch.object(settings, "PROJECTROOT", str(project_root)):
+				yield ctx
+			_config_cache.clear()
+
+		def test_git_get_type(self, repo_ctx: dict[str, str]) -> None:
+			assert git_get_type(repo_ctx["project"], "HEAD") == "commit"
+			assert git_get_type(repo_ctx["project"], "HEAD^{tree}") == "tree"
+
+		def test_git_parse_and_get_project_config(self, repo_ctx: dict[str, str]) -> None:
+			cfg: dict[str, object] = git_parse_project_config(repo_ctx["project"], "gitweb")
+			assert cfg["gitweb.owner"] == "alice"
+			assert cfg["gitweb.category"] == ["infra", "tools"]
+			assert git_get_project_config(repo_ctx["project"], "owner") == "alice"
+			assert git_get_project_config(repo_ctx["project"], "category") == ["infra", "tools"]
+
+		def test_git_get_file_or_project_config_prefers_file(self, repo_ctx: dict[str, str]) -> None:
+			assert git_get_file_or_project_config(repo_ctx["project"], "description") == "Repo description"
+			assert git_get_project_description(repo_ctx["project"]) == "Repo description"
+
+		def test_references_heads_tags_and_remotes(self, repo_ctx: dict[str, str]) -> None:
+			heads: list[tuple[str, str, str]] = git_get_heads_list(repo_ctx["project"])
+			head_names: set[str] = {name for name, _ref, _oid in heads}
+			assert {"main", "feature/readme"} <= head_names
+
+			refs: list[tuple[str, str]] = git_get_references(repo_ctx["project"])
+			assert any(name == "refs/heads/main" for name, _oid in refs)
+
+			tags: list[tuple[str, str, str]] = git_get_tags_list(repo_ctx["project"])
+			tag_names: set[str] = {name for name, _ref, _oid in tags}
+			assert {"v1.0", "lightweight"} <= tag_names
+
+			remotes: list[dict[str, object]] = git_get_remotes_info(repo_ctx["project"])
+			assert remotes == [
+				{
+					"name": "origin",
+					"url": "https://example.com/repo.git",
+					"push_url": "ssh://example.com/repo.git",
+				}
+			]
+
+		def test_parse_commit_and_parse_tag(self, repo_ctx: dict[str, str]) -> None:
+			commit_data: dict[str, object] = parse_commit(repo_ctx["project"], repo_ctx["second_commit"])
+			assert commit_data["subject"] == "update docs and app"
+			assert commit_data["author"] == "Alice"
+			assert isinstance(commit_data["parent"], list)
+
+			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / repo_ctx["project"]))
+			tag_oid: str = str(repo.references["refs/tags/v1.0"].target)
+			tag_data: dict[str, object] = parse_tag(repo_ctx["project"], tag_oid)
+			assert tag_data["type"] == "tag"
+			assert tag_data["message"] == "release v1.0\n"
+
+		def test_tree_blob_and_readme_resolution(self, repo_ctx: dict[str, str]) -> None:
+			tree_result = get_tree_at_ref_path(repo_ctx["project"], "HEAD", "docs")
+			assert tree_result is not None
+			tree, ref_oid = tree_result
+			assert ref_oid == repo_ctx["second_commit"]
+			assert tree["guide.txt"].type_str == "blob"
+
+			blob_result = get_blob_at_ref_path(repo_ctx["project"], "HEAD", "docs/guide.txt")
+			assert blob_result is not None
+			blob, _resolved_ref = blob_result
+			assert blob.data.decode("utf-8") == "hello\nworld\n"
+
+			readme_root = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "")
+			assert readme_root == ("README.md", "Root README\n")
+			readme_docs = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "docs")
+			assert readme_docs == ("README", "Docs README\n")
+
+		def test_commit_history_and_ranges(self, repo_ctx: dict[str, str]) -> None:
+			all_history: list[dict[str, object]] = get_commit_history(repo_ctx["project"], max_count=10)
+			assert [entry["oid"] for entry in all_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
+
+			path_history: list[dict[str, object]] = get_commit_history(
+				repo_ctx["project"],
+				path="docs/guide.txt",
+				max_count=10,
+			)
+			assert [entry["oid"] for entry in path_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
+
+			in_range: list[str] = get_commits_in_range(repo_ctx["project"], "HEAD", repo_ctx["first_commit"])
+			assert in_range == [repo_ctx["second_commit"]]
+
+		def test_blob_and_commit_unified_diff(self, repo_ctx: dict[str, str]) -> None:
+			blob_diff = get_blob_unified_diff(
+				repo_ctx["project"],
+				repo_ctx["first_commit"],
+				"src/app.py",
+				repo_ctx["second_commit"],
+				"src/app.py",
+			)
+			assert blob_diff is not None
+			assert "-print('v1')" in blob_diff
+			assert "+print('v2')" in blob_diff
+
+			commit_diff, oid_short = get_commit_unified_diff(repo_ctx["project"], "HEAD")
+			assert "docs/guide.txt" in commit_diff
+			assert "src/app.py" in commit_diff
+			assert oid_short == repo_ctx["second_commit"][:7]
diff --git a/pygitweb/validation_test.py b/pygitweb/validation_test.py
new file mode 100644
index 0000000..f90d133
--- /dev/null
+++ b/pygitweb/validation_test.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+import os
+import stat
+from collections.abc import Generator
+from pathlib import Path
+
+import pygit2
+import pytest
+
+from pygitweb.validation import check_export_ok, is_valid_pathname, is_valid_project, is_valid_ref_format
+
+
+class TestValidation:
+	@pytest.fixture(scope="class")
+	def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+		projectroot: Path = tmp_path_factory.mktemp("validation-projects")
+		valid_dir: Path = projectroot / "valid"
+		invalid_dir: Path = projectroot / "invalid"
+		unreachable_dir: Path = projectroot / "unreachable"
+
+		valid_dir.mkdir()
+		invalid_dir.mkdir()
+		unreachable_dir.mkdir()
+
+		pygit2.init_repository(str(valid_dir), bare=True)
+		(valid_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+		pygit2.init_repository(str(unreachable_dir), bare=True)
+		(unreachable_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+		original_mode: int = stat.S_IMODE(unreachable_dir.stat().st_mode)
+		os.chmod(unreachable_dir, 0)
+
+		try:
+			yield {
+				"projectroot": str(projectroot),
+				"valid_name": "valid",
+				"invalid_name": "invalid",
+				"unreachable_name": "unreachable",
+				"valid_dir": str(valid_dir),
+				"invalid_dir": str(invalid_dir),
+				"unreachable_dir": str(unreachable_dir),
+				"export_ok": "git-daemon-export-ok",
+			}
+		finally:
+			os.chmod(unreachable_dir, original_mode)
+
+	class TestPathname:
+		def test_is_valid_pathname_success(self) -> None:
+			assert is_valid_pathname("group/project.git") is True
+
+		@pytest.mark.parametrize("input_path", [None, "a//b", "a/./b", "a/../b", "a\0b"])
+		def test_is_valid_pathname_failure_modes(self, input_path: str | None) -> None:
+			assert is_valid_pathname(input_path) is False
+
+	class TestRefFormat:
+		def test_is_valid_ref_format_success(self) -> None:
+			assert is_valid_ref_format("refs/heads/main") is True
+
+		@pytest.mark.parametrize(
+			"input_ref",
+			[None, ".bad", "refs/heads/../x", "refs/heads/x.lock", "refs/heads/x ", "refs/heads/x?y"],
+		)
+		def test_is_valid_ref_format_failure_modes(self, input_ref: str | None) -> None:
+			assert is_valid_ref_format(input_ref) is False
+
+	class TestCheckExportOk:
+		def test_check_export_ok_success(self, project_env: dict[str, str]) -> None:
+			assert check_export_ok(project_env["valid_dir"], project_env["export_ok"]) is True
+
+		def test_check_export_ok_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
+			missing_dir: str = str(Path(project_env["projectroot"]) / "missing")
+			assert check_export_ok(missing_dir, project_env["export_ok"]) is False
+
+		def test_check_export_ok_fails_for_non_repo_directory(self, project_env: dict[str, str]) -> None:
+			assert check_export_ok(project_env["invalid_dir"], project_env["export_ok"]) is False
+
+		def test_check_export_ok_fails_for_missing_export_file(self, project_env: dict[str, str]) -> None:
+			assert check_export_ok(project_env["valid_dir"], "missing-export-ok") is False
+
+		def test_check_export_ok_fails_when_hook_rejects(self, project_env: dict[str, str]) -> None:
+			assert (
+				check_export_ok(
+					project_env["valid_dir"],
+					project_env["export_ok"],
+					export_auth_hook=lambda _path: False,
+				)
+				is False
+			)
+
+		def test_check_export_ok_fails_for_unreachable_repo(self, project_env: dict[str, str]) -> None:
+			assert check_export_ok(project_env["unreachable_dir"], project_env["export_ok"]) is False
+
+	class TestValidProject:
+		def test_is_valid_project_success(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project=project_env["valid_name"],
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=False,
+					project_in_list=lambda _project: True,
+				)
+				is True
+			)
+
+		def test_is_valid_project_fails_for_none(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project=None,
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=False,
+					project_in_list=lambda _project: True,
+				)
+				is False
+			)
+
+		def test_is_valid_project_fails_for_invalid_pathname(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project="bad/../name",
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=False,
+					project_in_list=lambda _project: True,
+				)
+				is False
+			)
+
+		def test_is_valid_project_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project="missing",
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=False,
+					project_in_list=lambda _project: True,
+				)
+				is False
+			)
+
+		def test_is_valid_project_fails_when_export_check_fails(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project=project_env["invalid_name"],
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=False,
+					project_in_list=lambda _project: True,
+				)
+				is False
+			)
+
+		def test_is_valid_project_fails_strict_export_when_not_listed(self, project_env: dict[str, str]) -> None:
+			assert (
+				is_valid_project(
+					project=project_env["valid_name"],
+					projectroot=project_env["projectroot"],
+					export_ok=project_env["export_ok"],
+					strict_export=True,
+					project_in_list=lambda _project: False,
+				)
+				is False
+			)
