diff --git a/pygitweb/actions.py b/pygitweb/actions.py
new file mode 100644
index 0000000..4cf1599
--- /dev/null
+++ b/pygitweb/actions.py
@@ -0,0 +1,792 @@
+"""
+Gitweb action handlers (git_*): summary, tree, blob, log, commit, tag, etc.
+Ported from gitweb/gitweb.perl action handlers. Invoked by main.dispatch.
+"""
+from __future__ import annotations
+
+import base64
+import mimetypes
+import os
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from urllib.parse import quote, urlencode
+
+import pygit2
+
+from fastapi import HTTPException, Request
+from fastapi.responses import HTMLResponse, PlainTextResponse, Response
+
+from pygitweb import config
+from pygitweb.config import BLOB_LANG
+from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
+from pygitweb.git_helpers import (
+    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_head_hash,
+    git_get_project_description,
+    git_get_remotes_info,
+    git_get_tags_list,
+    git_get_type,
+    git_get_heads_list,
+    open_repo,
+    parse_commit,
+    parse_tag,
+)
+from pygitweb.projects import git_get_project_owner
+from pygitweb.templates_env import PREAMBLE, POSTAMBLE, env
+from pygitweb.validation import is_valid_pathname, is_valid_ref_format
+
+
+def _pagination_url(request: Request, page: int, pagecount: int) -> str:
+    """Build URL for a pagination page, preserving path and other query params."""
+    params = dict(request.query_params)
+    params["page"] = str(page)
+    params["pagecount"] = str(pagecount)
+    return f"{request.url.path}?{urlencode(params)}"
+
+
+def parse_pagination(
+    page: str | None,
+    pagecount: str | None,
+) -> tuple[int, int]:
+    """Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
+    p = 1
+    pc = 25
+    if page is not None:
+        try:
+            p = int(page)
+        except ValueError:
+            raise HTTPException(status_code=400, detail="page must be an integer")
+        if p < 1:
+            raise HTTPException(status_code=400, detail="page must be at least 1")
+    if pagecount is not None:
+        try:
+            pc = int(pagecount)
+        except ValueError:
+            raise HTTPException(status_code=400, detail="pagecount must be an integer")
+        if pc < 1:
+            raise HTTPException(status_code=400, detail="pagecount must be at least 1")
+        if pc > 50:
+            raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
+    return p, pc
+
+
+def _tree_url(project: str, h: str, f: str | None, a: str = "tree") -> str:
+    """Build URL for tree or blob: /project?a=a&h=h&f=f with f quoted."""
+    q = f"a={a}&h={quote(h, safe='')}"
+    if f:
+        q += f"&f={quote(f, safe='/')}"
+    return f"/{project}?{q}"
+
+
+def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
+    """Format epoch timestamp to readable date string.
+    tz_offset is in minutes (as returned by pygit2).
+    """
+    if epoch is None:
+        return ""
+    try:
+        if tz_offset is not None:
+            tz = timezone(timedelta(seconds=tz_offset * 60))
+        else:
+            tz = timezone.utc
+        dt = datetime.fromtimestamp(epoch, tz=tz)
+        return dt.strftime("%Y-%m-%d %H:%M:%S")
+    except (ValueError, OSError):
+        return ""
+
+
+def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list:
+    """Format a single commit as a table row."""
+    oid = commit.get("oid", "")
+    oid_short = oid[:7] if oid else ""
+    subject = commit.get("subject", "")
+    author = commit.get("author", "")
+    author_epoch = commit.get("author_epoch")
+
+    date_str = _format_date(author_epoch, commit.get("author_tz"))
+    age_sec = None
+    if author_epoch:
+        age_sec = datetime.now(timezone.utc).timestamp() - author_epoch
+        age_str = age_string(age_sec) if age_sec > 0 else "right now"
+    else:
+        age_str = ""
+
+    commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
+    diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
+    author_display = esc_html(author) or "unknown"
+
+    if short:
+        return [
+            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
+            esc_html(subject),
+            author_display,
+            esc_html(age_str),
+            f'<a href="{diff_link}">diff</a>',
+        ]
+    return [
+        f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
+        esc_html(subject),
+        author_display,
+        esc_html(date_str),
+        esc_html(age_str),
+        f'<a href="{diff_link}">diff</a>',
+    ]
+
+
+def _render_pagination(
+    request: Request,
+    page: int,
+    pagecount: int,
+    has_prev: bool,
+    has_next: bool,
+    total_pages: int | None = None,
+) -> str:
+    """Render Tabler pagination HTML."""
+    prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
+    next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
+    page_links = None
+    if total_pages is not None and total_pages <= 20:
+        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
+    elif total_pages is not None:
+        start = max(1, page - 2)
+        end = min(total_pages, page + 2)
+        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
+        if start > 1:
+            page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
+        if end < total_pages:
+            page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
+    return env.get_template("pagination.html").render(
+        current_page=page,
+        pagecount=pagecount,
+        has_prev=has_prev,
+        has_next=has_next,
+        prev_url=prev_url,
+        next_url=next_url,
+        total_pages=total_pages,
+        page_links=page_links,
+    )
+
+
+def _render_readme_card(
+    project: str,
+    ref_oid: str,
+    readme_filename: str,
+    readme_content: str,
+    blob_dir: str = "",
+) -> str:
+    """Return HTML for the README card (and script for markdown). blob_dir is the current tree path for relative links."""
+    is_markdown = readme_filename.lower().endswith(".md")
+    blob_base = f"/{project}?a=blob&h={quote(ref_oid, safe='')}&f="
+    blob_base_attr = blob_base.replace("&", "&amp;").replace('"', "&quot;")
+    blob_dir_attr = blob_dir.replace("&", "&amp;").replace('"', "&quot;") if blob_dir else ""
+    if is_markdown:
+        readme_b64 = base64.b64encode(readme_content.encode("utf-8")).decode("ascii")
+        card = (
+            '<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
+            '<div class="card-body"><div id="readme-container" class="markdown-body" data-readme-b64="'
+            + readme_b64
+            + '" data-blob-base="'
+            + blob_base_attr
+            + '"'
+        )
+        if blob_dir_attr:
+            card += ' data-blob-dir="' + blob_dir_attr + '"'
+        card += '></div></div></div><script src="/static/readme-render.js"></script>'
+        return card
+    return (
+        '<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
+        '<div class="card-body"><pre class="readme-plain"><code>'
+        + esc_html(sanitize(readme_content) or "")
+        + "</code></pre></div></div>"
+    )
+
+
+def _commit_unified_diff_or_raise(project: str, h: str) -> tuple[str, str]:
+    """Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        return get_commit_unified_diff(project, h)
+    except (KeyError, pygit2.GitError, OSError, ValueError):
+        raise HTTPException(status_code=404, detail="Commit not found")
+
+
+# ---------- Action handlers ----------
+
+
+def git_object(project: str, h: str | None) -> Response:
+    """Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Object hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    obj_type = git_get_type(project, h)
+    if not obj_type:
+        raise HTTPException(status_code=404, detail="Object not found")
+    if obj_type == "commit":
+        return git_commit(project, h)
+    if obj_type == "tree":
+        return git_tree(project, h, None)
+    if obj_type == "tag":
+        return git_tag(project, h)
+    if obj_type == "blob":
+        try:
+            repo = open_repo(project)
+            obj = repo.revparse_single(h)
+        except (KeyError, pygit2.GitError, OSError):
+            raise HTTPException(status_code=404, detail="Object not found")
+        if not isinstance(obj, pygit2.Blob):
+            raise HTTPException(status_code=404, detail="Not a blob")
+        data = obj.data
+        text = to_utf8(data) or ""
+        body = esc_html(sanitize(text) or "") or ""
+        lines = text.split("\n")
+        num_lines = max(1, len(lines))
+        line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
+        oid_short = str(obj.id)[:7]
+        blob_html = (
+            f"<div class=\"blob-view\">"
+            f"<div class=\"blob-line-nums\" aria-hidden=\"true\">{esc_html(line_nums)}</div>"
+            f"<pre class=\"blob-content\"><code class=\"hljs\">{body}</code></pre>"
+            f"</div>"
+        )
+        pre = PREAMBLE.render(
+            title=f"Blob {oid_short} - {esc_html(project)}",
+            site_name=config.SITE_NAME,
+        )
+        return HTMLResponse(f"{pre}<h1>Blob {esc_html(oid_short)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{blob_html}{POSTAMBLE}")
+    raise HTTPException(status_code=404, detail="Unknown object type")
+
+
+def git_summary(project: str) -> HTMLResponse:
+    """Project summary page. Port of git_summary."""
+    descr = git_get_project_description(project) or "none"
+    owner = git_get_project_owner(project) or ""
+    head = git_get_head_hash(project)
+    head_short = head[:7] if head else ""
+
+    table = env.get_template("table.html").render(
+        cols=["Field", "Value"],
+        rows=[
+            ["Description", esc_html(descr)],
+            ["Owner", esc_html(owner)],
+            [esc_html("HEAD"), f"<a href='/{project}?a=commit&h={quote(head or '', safe='')}'>{head_short or 'N/A'}</a>"],
+            [esc_html("tree"), f"<a href='/{project}?a=tree&h={head or ''}'>browse</a>"],
+            ["Log", f"<a href='/{project}?a=log&h={head or ''}'>view log</a>"],
+            ["Shortlog", f"<a href='/{project}?a=shortlog&h={head or ''}'>view shortlog</a>"],
+            ["Heads", f"<a href='/{project}?a=heads'>view heads</a>"],
+            ["Tags", f"<a href='/{project}?a=tags'>view tags</a>"],
+            ["Remotes", f"<a href='/{project}?a=remotes'>view remotes</a>"],
+        ],
+    )
+    pre = PREAMBLE.render(title=f"{esc_html(config.SITE_NAME)} - {project}", site_name=config.SITE_NAME)
+    body_parts = [f"{pre}<h1>{esc_html(project)}</h1>{table}"]
+
+    readme = get_readme_at_ref_path(project, head, "")
+    if readme:
+        readme_filename, readme_content = readme
+        body_parts.append(_render_readme_card(project, head or "", readme_filename, readme_content, ""))
+
+    body_parts.append(POSTAMBLE)
+    return HTMLResponse("".join(body_parts))
+
+
+def git_remotes(project: str) -> HTMLResponse:
+    """Remotes page: list configured remotes (name, url, push_url)."""
+    remotes = git_get_remotes_info(project)
+    if not remotes:
+        pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
+        return HTMLResponse(
+            f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}"
+        )
+    rows = []
+    for r in remotes:
+        name = esc_html(r["name"])
+        url = esc_html(r["url"] or "—")
+        push_url = esc_html(r["push_url"] or "—")
+        rows.append([name, url, push_url])
+    table = env.get_template("table.html").render(
+        cols=["Name", "URL", "Push URL"],
+        rows=rows,
+    )
+    pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
+    return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
+
+
+def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
+    """Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required")
+    if not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    result = get_blob_at_ref_path(project, h, f)
+    if not result:
+        raise HTTPException(status_code=404, detail="File not found")
+    blob, _ = result
+    data = blob.data
+    if raw:
+        media_type, _ = mimetypes.guess_type(f.split("/")[-1])
+        if media_type is None:
+            try:
+                data.decode("utf-8")
+                media_type = "text/plain; charset=utf-8"
+            except UnicodeDecodeError:
+                media_type = "application/octet-stream"
+        return Response(content=data, media_type=media_type)
+    text = to_utf8(data) or ""
+    body = esc_html(sanitize(text) or "") or ""
+    lines = text.split("\n")
+    num_lines = max(1, len(lines))
+    line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
+    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
+    lang = BLOB_LANG.get(ext, "")
+    lang_attr = f" language-{lang}" if lang else ""
+    blob_html = (
+        f"<div class=\"blob-view\">"
+        f"<div class=\"blob-line-nums\" aria-hidden=\"true\">{esc_html(line_nums)}</div>"
+        f"<pre class=\"blob-content\"><code class=\"hljs{lang_attr}\">{body}</code></pre>"
+        f"</div>"
+    )
+    blob_script = '<script src="static/blob-view.js"></script>'
+    pre = PREAMBLE.render(title=f"{esc_html(f)} - {esc_html(project)}", site_name=config.SITE_NAME)
+    return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
+
+
+def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
+    """Tree page: list files and directories; directories link to tree with f=path."""
+    if f is not None and not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    result = get_tree_at_ref_path(project, h, f)
+    if not result:
+        raise HTTPException(status_code=404, detail="Tree or path not found")
+    tree, ref_oid = result
+    base = f"/{project}"
+    breadcrumbs = [f'<a href="{base}">{esc_html(project)}</a>']
+    if f:
+        parts = f.strip("/").split("/")
+        for i, seg in enumerate(parts):
+            prefix = "/".join(parts[: i + 1])
+            breadcrumbs.append(
+                f' / <a href="{_tree_url(project, ref_oid, prefix)}">{esc_html(seg)}</a>'
+            )
+    breadcrumb_html = "".join(breadcrumbs)
+    entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
+    dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
+    blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
+    rows = []
+    for name, typ, _ in dirs:
+        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
+        link = _tree_url(project, ref_oid, sub_path)
+        rows.append(
+            [f'<a href="{link}">{esc_html(name)}/</a>', "tree"]
+        )
+    for name, typ, _ in blobs:
+        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
+        link = _tree_url(project, ref_oid, sub_path, a="blob")
+        rows.append(
+            [f'<a href="{link}">{esc_html(name)}</a>', "blob"]
+        )
+    title_path = f" / {f}" if f else ""
+    pre = PREAMBLE.render(title=f"{esc_html(project)}{esc_html(title_path)} - Tree", site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Name", "Type"],
+        rows=rows
+    )
+    body_parts = [
+        f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{esc_html(title_path)}</h1>{table}"
+    ]
+    readme = get_readme_at_ref_path(project, ref_oid, f or "")
+    if readme:
+        readme_filename, readme_content = readme
+        body_parts.append(_render_readme_card(project, ref_oid, readme_filename, readme_content, f or ""))
+    body_parts.append(POSTAMBLE)
+    return HTMLResponse("".join(body_parts))
+
+
+def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
+    """Commit log page. Port of git_log."""
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Log - {esc_html(project)} @ {esc_html(ref_display)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
+        rows=rows
+    )
+    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}"
+    )
+
+
+def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
+    """Shortlog page. Port of git_shortlog."""
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=True))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Shortlog - {esc_html(project)} @ {esc_html(ref_display)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Commit", "Subject", "Author", "Age", "Diff"],
+        rows=rows
+    )
+    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}"
+    )
+
+
+def git_history(project: str, h: str | None, f: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
+    """History page for a file or directory. Port of git_history."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for history")
+    if not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No history found for this path")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"History - {esc_html(f)} - {esc_html(project)} @ {esc_html(ref_display)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
+        rows=rows
+    )
+    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{pagination_html}{POSTAMBLE}"
+    )
+
+
+def git_heads(project: str) -> HTMLResponse:
+    """Heads (branches) list page. Port of git_heads."""
+    heads_list = git_get_heads_list(project)
+    if not heads_list:
+        raise HTTPException(status_code=404, detail="No heads found")
+    rows = []
+    for name, _ref, oid in heads_list:
+        oid_short = oid[:7] if oid else ""
+        commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
+        tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
+        rows.append([
+            f'<a href="{commit_link}">{esc_html(name)}</a>',
+            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
+            f'<a href="{tree_link}">tree</a>',
+        ])
+    title = f"Heads - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Head", "Commit", ""],
+        rows=rows,
+    )
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
+def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
+    """Tags list page. Port of git_tags."""
+    tags_list = git_get_tags_list(project)
+    if not tags_list:
+        raise HTTPException(status_code=404, detail="No tags found")
+    total = len(tags_list)
+    total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
+    if page > total_pages and total > 0:
+        raise HTTPException(status_code=404, detail="Page does not exist")
+    skip = (page - 1) * pagecount
+    tags_page = tags_list[skip : skip + pagecount]
+    try:
+        repo = open_repo(project)
+    except Exception:
+        raise HTTPException(status_code=404, detail="Repository not found")
+    rows = []
+    for name, _ref, oid in tags_page:
+        tag_link = f"/{project}?a=tag&h={quote(oid, safe='')}"
+        target_oid = oid
+        target_type = "commit"
+        try:
+            obj = repo.revparse_single(oid)
+            if isinstance(obj, pygit2.Tag):
+                target_oid = str(obj.target)
+                target_type = obj.type_str
+        except (KeyError, pygit2.GitError):
+            pass
+        target_short = target_oid[:7] if target_oid else ""
+        if target_type == "commit":
+            target_link = f"/{project}?a=commit&h={quote(target_oid, safe='')}"
+        elif target_type == "tree":
+            target_link = f"/{project}?a=tree&h={quote(target_oid, safe='')}"
+        else:
+            target_link = None
+        obj_cell = f'<a href="{target_link}">{esc_html(target_short)}</a>' if target_link else esc_html(target_short)
+        rows.append([
+            f'<a href="{tag_link}">{esc_html(name)}</a>',
+            obj_cell,
+        ])
+    title = f"Tags - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Tag", "Object"],
+        rows=rows,
+    )
+    pagination_html = _render_pagination(
+        request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages
+    )
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{pagination_html}{POSTAMBLE}"
+    )
+
+
+def git_tag(project: str, h: str | None) -> HTMLResponse:
+    """Single tag view. Port of git_tag."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError):
+        raise HTTPException(status_code=404, detail="Tag or object not found")
+    if not isinstance(obj, pygit2.Tag):
+        raise HTTPException(status_code=404, detail="Not a tag object")
+    tag_oid = str(obj.id)
+    tag_data = parse_tag(project, tag_oid)
+    if not tag_data:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    target_oid = tag_data.get("object", "")
+    target_type = tag_data.get("type", "commit")
+    tagger = tag_data.get("tagger", "")
+    tagger_epoch = tag_data.get("tagger_epoch")
+    tagger_tz = tag_data.get("tagger_tz")
+    message = (tag_data.get("message") or "").strip()
+    tag_name = None
+    for name, _ref, oid in git_get_tags_list(project):
+        if oid == tag_oid:
+            tag_name = name
+            break
+    if tag_name is None:
+        tag_name = tag_oid[:7]
+    target_short = target_oid[:7] if target_oid else ""
+    if target_type == "commit":
+        object_link = f'<a href="/{project}?a=commit&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+    elif target_type == "tree":
+        object_link = f'<a href="/{project}?a=tree&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+    else:
+        object_link = esc_html(target_short)
+    table_rows = [
+        ["Tag", esc_html(tag_name)],
+        ["Object", f"{object_link} ({esc_html(target_type)})"],
+        ["Tagger", esc_html(tagger)],
+        ["Date", esc_html(_format_date(tagger_epoch, tagger_tz))],
+    ]
+    if message:
+        table_rows.append(["Message", f"<pre class='tag-message'>{esc_html(message)}</pre>"])
+    table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
+    title = f"Tag {esc_html(tag_name)} - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    return HTMLResponse(
+        f"{pre}<h1>Tag {esc_html(tag_name)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
+def git_commit(project: str, h: str | None) -> HTMLResponse:
+    """Single commit view. Port of git_commit (git show)."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError):
+        raise HTTPException(status_code=404, detail="Commit not found")
+    if not isinstance(obj, pygit2.Commit):
+        raise HTTPException(status_code=404, detail="Not a commit object")
+    commit = obj
+    oid = str(commit.id)
+    oid_short = oid[:7]
+    data = parse_commit(project, oid)
+    author = data.get("author", "")
+    author_email = data.get("author_email", "")
+    author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
+    tree_oid = data.get("tree", "")
+    message = (data.get("body") or "").strip()
+    commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
+    tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
+    diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
+    patch_link = f"/{project}?a=patch&h={quote(oid, safe='')}"
+    table_rows = [
+        ["commit", f'<a href="{commit_link}">{esc_html(oid)}</a>'],
+        ["Author", f"{esc_html(author)} &lt;{esc_html(author_email)}&gt;"],
+        ["Date", esc_html(author_date)],
+        ["tree", f'<a href="{tree_link}">{esc_html(tree_oid[:7])}</a>'],
+    ]
+    for i, parent_id in enumerate(commit.parent_ids):
+        parent_oid = str(parent_id)
+        parent_short = parent_oid[:7]
+        parent_link = f"/{project}?a=commit&h={quote(parent_oid, safe='')}"
+        label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
+        table_rows.append([label, f'<a href="{parent_link}">{esc_html(parent_short)}</a>'])
+    if message:
+        table_rows.append(["", f"<pre class='commit-message'>{esc_html(message)}</pre>"])
+    table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
+    table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
+    title = f"Commit {esc_html(oid_short)} - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    return HTMLResponse(
+        f"{pre}<h1>Commit {esc_html(oid_short)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
+def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
+    """Commit diff page: unified diff rendered with diff2html (script id diff2html-script)."""
+    diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
+    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
+    title = f"Commit diff {oid_short} - {project}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    body = env.get_template("commitdiff.html").render(
+        project=project,
+        oid_short=oid_short,
+        diff_b64=diff_b64,
+        commit_link=f"/{project}?a=commit&h={quote(h or '', safe='')}",
+    )
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+def git_patch(project: str, h: str | None) -> PlainTextResponse:
+    """Single-commit patch (plain text unified diff)."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    body, oid_short = _commit_unified_diff_or_raise(project, h)
+    filename = f"{project}-{oid_short}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
+
+
+def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
+    """Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if hb:
+        oids = get_commits_in_range(project, h, hb)
+        if not oids:
+            raise HTTPException(status_code=404, detail="No commits in range")
+        parts = []
+        for oid in oids:
+            try:
+                diff, _ = get_commit_unified_diff(project, oid)
+                if diff:
+                    parts.append(diff)
+            except (KeyError, pygit2.GitError, OSError, ValueError):
+                pass
+        body = "\n".join(parts)
+        filename = f"{project}-{h[:7]}-patches.patch"
+    else:
+        body, oid_short = _commit_unified_diff_or_raise(project, h)
+        filename = f"{project}-{oid_short}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
+
+
+def git_blobdiff(
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
+) -> HTMLResponse:
+    """Blob diff page: diff between two blob versions, rendered with diff2html."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if diff_text is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
+    blob_link = f"/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
+    pre = PREAMBLE.render(
+        title=f"Blob diff - {esc_html(path_new)} - {esc_html(project)}",
+        site_name=config.SITE_NAME,
+    )
+    body = env.get_template("blobdiff.html").render(
+        project=project,
+        path_new=path_new,
+        diff_b64=diff_b64,
+        blob_link=blob_link,
+    )
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+def git_blobpatch(
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
+) -> PlainTextResponse:
+    """Blob diff as plain unified diff."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    body = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if body is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
index 8c382da..ce29bef 100644
--- a/pygitweb/git_helpers.py
+++ b/pygitweb/git_helpers.py
@@ -17,6 +17,10 @@ import pygit2
 
 # From config
 from pygitweb.config import PROJECTROOT, GIT
+from pygitweb.formatting import to_utf8
+
+# Common README filenames to look for (order matters: prefer README.md)
+README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]
 
 
 def _repo_path(project: str) -> str:
@@ -164,11 +168,7 @@ def git_get_hash_by_path(project: str, base: str, path: str, obj_type: str | Non
     """OID of path at base (tree-ish). Port of git_get_hash_by_path (pygit2 tree path lookup)."""
     try:
         repo = open_repo(project)
-        commit_or_tree = repo.revparse_single(base)
-        if hasattr(commit_or_tree, "tree"):
-            tree = commit_or_tree.tree
-        else:
-            tree = commit_or_tree
+        tree = repo.revparse_single(base).peel(pygit2.Tree)
         path = path.rstrip("/")
         entry = tree / path
         if not entry:
@@ -195,17 +195,14 @@ def get_tree_at_ref_path(
             return None
         obj = repo.revparse_single(base_ref)
         ref_oid = str(obj.id)
-        base_tree = obj.tree if hasattr(obj, "tree") else obj
+        base_tree = obj.peel(pygit2.Tree)
         if not path or not path.strip("/"):
             return (base_tree, ref_oid)
         path_clean = path.strip("/")
         entry = base_tree / path_clean
         if not entry or entry.type_str != "tree":
             return None
-        subtree = repo[entry.id]
-        if not isinstance(subtree, pygit2.Tree):
-            return None
-        return (subtree, ref_oid)
+        return (repo[entry.id].peel(pygit2.Tree), ref_oid)
     except (KeyError, pygit2.GitError, OSError):
         return None
 
@@ -224,15 +221,12 @@ def get_blob_at_ref_path(project: str, ref: str | None, path: str | None) -> tup
             return None
         obj = repo.revparse_single(base_ref)
         ref_oid = str(obj.id)
-        base_tree = obj.tree if hasattr(obj, "tree") else obj
+        base_tree = obj.peel(pygit2.Tree)
         path_clean = path.strip("/")
         entry = base_tree / path_clean
         if not entry or entry.type_str != "blob":
             return None
-        blob = repo[entry.id]
-        if not isinstance(blob, pygit2.Blob):
-            return None
-        return (blob, ref_oid)
+        return (repo[entry.id].peel(pygit2.Blob), ref_oid)
     except (KeyError, pygit2.GitError, OSError):
         return None
 
@@ -241,8 +235,7 @@ def git_get_path_by_hash(project: str, base: str, oid_str: str) -> str | None:
     """Path of object with given OID in base tree. Port of git_get_path_by_hash."""
     try:
         repo = open_repo(project)
-        commit_or_tree = repo.revparse_single(base)
-        tree = commit_or_tree.tree if hasattr(commit_or_tree, "tree") else commit_or_tree
+        tree = repo.revparse_single(base).peel(pygit2.Tree)
 
         def find_in_tree(t: pygit2.Tree, prefix: str) -> str | None:
             for e in t:
@@ -289,13 +282,12 @@ def git_get_references(project: str, ref_prefix: str = "refs/heads") -> list[tup
     """List (ref_name, oid) for prefix. Port of git_get_references (pygit2 references)."""
     try:
         repo = open_repo(project)
-        refs = []
-        for ref_name in repo.references:
-            if ref_name.startswith(ref_prefix + "/"):
-                r = repo.references[ref_name]
-                target = r.target if hasattr(r, "target") else r.resolve()
-                refs.append((ref_name, str(target)))
-        return refs
+        prefix = ref_prefix + "/"
+        return [
+            (ref_name, str(repo.references[ref_name].resolve().target))
+            for ref_name in repo.references
+            if ref_name.startswith(prefix)
+        ]
     except (pygit2.GitError, OSError):
         return []
 
@@ -320,19 +312,16 @@ def _tag_timestamp(repo: pygit2.Repository, oid: str) -> int:
 
 
 def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
-    """List (name, ref, oid) for tags using pygit2 references.iterator(GIT_REFERENCES_TAGS).
-    Sorted by tag creation time descending (newest first); lightweight tags use commit time.
-    """
+    """List (name, ref, oid) for tags. Sorted by tag creation time descending (newest first)."""
     try:
         repo = open_repo(project)
         result = []
         for ref in repo.references.iterator(pygit2.GIT_REFERENCES_TAGS):
-            r = ref.resolve() if isinstance(ref.target, str) else ref
-            oid = str(r.target)
+            oid = str(ref.resolve().target)
             name = ref.name.replace("refs/tags/", "")
             ts = _tag_timestamp(repo, oid)
             result.append((name, ref.name, oid, ts))
-        result.sort(key=lambda x: (x[3], x[0]), reverse=True)  # by time desc, then name desc
+        result.sort(key=lambda x: (x[3], x[0]), reverse=True)
         return [(name, ref_name, oid) for name, ref_name, oid, _ in result]
     except (pygit2.GitError, OSError):
         return []
@@ -426,21 +415,13 @@ def get_commit_history(
     """
     try:
         repo = open_repo(project)
-        start_oid = None
         if ref:
-            obj = repo.revparse_single(ref)
-            if isinstance(obj, pygit2.Commit):
-                start_oid = obj.id
-            elif hasattr(obj, "target"):
-                # Tag or other object with target
-                target = repo[obj.target]
-                if isinstance(target, pygit2.Commit):
-                    start_oid = target.id
+            try:
+                start_oid = repo.revparse_single(ref).peel(pygit2.Commit).id
+            except (KeyError, pygit2.GitError, ValueError):
+                start_oid = None
         else:
-            # Default to HEAD
-            if repo.head:
-                start_oid = repo.head.target
-        
+            start_oid = repo.head.target if repo.head else None
         if not start_oid:
             return []
         
@@ -449,56 +430,41 @@ def get_commit_history(
         walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
         
         if path:
-            # Filter by path: only commits that touched this path
+            # Filter by path: only commits that touched this path (or a file under it)
             path_clean = path.strip("/")
+            path_prefix = path_clean + "/"
+
+            def touched_path(diff: pygit2.Diff) -> bool:
+                for delta in diff.deltas:
+                    old_p, new_p = delta.old_file.path, delta.new_file.path
+                    if old_p == path_clean or new_p == path_clean:
+                        return True
+                    if old_p.startswith(path_prefix) or new_p.startswith(path_prefix):
+                        return True
+                return False
+
             for commit in walker:
                 if len(commits) >= max_count:
                     break
-                # Check if this commit touched the path
                 try:
-                    # Get tree for this commit
-                    tree = commit.tree
-                    # Check if path exists in this commit's tree
-                    entry = None
-                    try:
-                        entry = tree / path_clean if path_clean else None
-                    except (KeyError, AttributeError):
-                        pass
-                    
-                    # Check if path was changed in this commit (compare with parent)
-                    path_changed = False
                     if commit.parents:
-                        parent = commit.parents[0]
-                        try:
-                            parent_tree = parent.tree
-                            parent_entry = None
-                            try:
-                                parent_entry = parent_tree / path_clean if path_clean else None
-                            except (KeyError, AttributeError):
-                                pass
-                            
-                            # Path changed if it exists in one but not the other, or OIDs differ
-                            if (parent_entry is None) != (entry is None):
-                                path_changed = True
-                            elif parent_entry is not None and entry is not None:
-                                if str(parent_entry.id) != str(entry.id):
-                                    path_changed = True
-                        except (KeyError, AttributeError):
-                            # If we can't compare, assume it changed if entry exists
-                            path_changed = entry is not None
-                    else:
-                        # Root commit: include if path exists
-                        path_changed = entry is not None
-                    
-                    if path_changed or entry:
-                        if skipped < skip:
-                            skipped += 1
+                        diff = repo.diff(commit.parents[0], commit)
+                        if not touched_path(diff):
                             continue
-                        commit_data = parse_commit(project, str(commit.id))
-                        commit_data["oid"] = str(commit.id)
-                        commits.append(commit_data)
-                except (KeyError, AttributeError):
-                    # Skip commits we can't process
+                    else:
+                        # Root commit: include if path is root or path exists in tree
+                        if path_clean:
+                            try:
+                                commit.tree / path_clean
+                            except KeyError:
+                                continue
+                    if skipped < skip:
+                        skipped += 1
+                        continue
+                    commit_data = parse_commit(project, str(commit.id))
+                    commit_data["oid"] = str(commit.id)
+                    commits.append(commit_data)
+                except (KeyError, AttributeError, pygit2.GitError):
                     pass
         else:
             # No path filter, get all commits
@@ -521,21 +487,15 @@ def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
     """Return list of commit OIDs from tip back to (but not including) base. Newest first."""
     try:
         repo = open_repo(project)
-        tip_commit = repo.revparse_single(tip)
-        if not isinstance(tip_commit, pygit2.Commit):
-            return []
-        base_oid = None
+        tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
+        walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
         if base:
             try:
-                base_obj = repo.revparse_single(base)
-                base_oid = base_obj.id
-            except (KeyError, pygit2.GitError):
+                walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
+            except (KeyError, pygit2.GitError, ValueError):
                 pass
-        walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
-        if base_oid is not None:
-            walker.hide(base_oid)
         return [str(c.id) for c in walker]
-    except (KeyError, pygit2.GitError, OSError):
+    except (KeyError, pygit2.GitError, OSError, ValueError):
         return []
 
 
@@ -558,3 +518,38 @@ def get_blob_unified_diff(
     new_blob, _ = result_new
     patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
     return patch.text if patch.text else ""
+
+
+def get_readme_at_ref_path(
+    project: str, ref: str | None, dir_path: str | None
+) -> tuple[str, str] | None:
+    """
+    If a README exists at ref in the given tree (dir_path), return (filename, utf8_content).
+    Otherwise None. dir_path is the tree path (e.g. '' for root, 'docs' for docs/).
+    """
+    if not ref:
+        return None
+    for name in README_CANDIDATES:
+        path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
+        result = get_blob_at_ref_path(project, ref, path)
+        if result:
+            blob, _ = result
+            text = to_utf8(blob.data) or ""
+            return (name, text)
+    return None
+
+
+def get_commit_unified_diff(project: str, h: str) -> tuple[str, str]:
+    """
+    Return (unified_diff_text, oid_short) for commit h.
+    Raises KeyError, pygit2.GitError, OSError on error (caller should validate ref and map to HTTPException).
+    """
+    repo = open_repo(project)
+    commit = repo.revparse_single(h).peel(pygit2.Commit)
+    if commit.parents:
+        diff = repo.diff(commit.parents[0], commit)
+    else:
+        diff = commit.tree.diff_to_tree(swap=True)
+    body = diff.patch or ""
+    oid_short = commit.short_id[:7] if len(commit.short_id) >= 7 else commit.short_id
+    return body, oid_short
diff --git a/pygitweb/main.py b/pygitweb/main.py
index dc5314f..94af50a 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -4,86 +4,66 @@ Ported from gitweb/gitweb.perl dispatch and action handlers.
 """
 from __future__ import annotations
 
-import base64
-import mimetypes
 import os
 import subprocess
 import tempfile
 import zipfile
-from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from typing import Annotated, Any
+from typing import Annotated
 
 import pygit2
 from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
-from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
+from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
 from fastapi.staticfiles import StaticFiles
 
-from jinja2 import Template, Environment, PackageLoader
-
 from pygitweb import config, __meta__
 from pygitweb.config import (
     ACTIONS,
-    BLOB_LANG,
     DISTGIT_AUTH,
     DISTGIT_ADMIN_USER,
     DISTGIT_ADMIN_PASSWORD,
     DISTGIT_SESSION_TIMEOUT,
     EXPORT_OK,
     PROJECTROOT,
-    PROJECTS_LIST,
     STRICT_EXPORT,
     check_loadavg,
     configure_gitweb_features,
     evaluate_gitweb_config,
-    get_snapshot_fmts,
-)
-from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
-from pygitweb.git_helpers import (
-    get_blob_at_ref_path,
-    get_blob_unified_diff,
-    get_commit_history,
-    get_commits_in_range,
-    get_tree_at_ref_path,
-    git_get_head_hash,
-    git_get_heads_list,
-    git_get_project_description,
-    git_get_remotes_info,
-    git_get_tags_list,
-    parse_commit,
-    parse_tag,
-)
-from pygitweb.projects import (
-    git_get_projects_list,
-    git_get_project_list_from_file,
-    git_get_project_owner,
-    project_in_list,
 )
-from pygitweb.validation import (
-    check_export_ok,
-    is_valid_action,
-    is_valid_pathname,
-    is_valid_project,
-    is_valid_ref_format,
+from pygitweb.formatting import esc_html
+from pygitweb.git_helpers import git_get_project_config, git_get_type
+from pygitweb.projects import git_get_projects_list, git_get_project_owner
+from pygitweb.templates_env import PREAMBLE, POSTAMBLE, env
+from pygitweb.validation import is_valid_action, is_valid_pathname, is_valid_project, is_valid_ref_format
+
+from pygitweb.actions import (
+    git_blob,
+    git_blobdiff,
+    git_blobpatch,
+    git_commit,
+    git_commitdiff,
+    git_heads,
+    git_history,
+    git_log,
+    git_object,
+    git_patch,
+    git_patches,
+    git_remotes,
+    git_shortlog,
+    git_summary,
+    git_tag,
+    git_tags,
+    git_tree,
+    parse_pagination,
 )
-from urllib.parse import quote, urlencode
 
 app = FastAPI(
     debug=(DISTGIT_AUTH == "None"),
     title="PyGitWeb",
     summary="FastAPI + Pygit2 Repo Browser",
     description=open("pygitweb/README.md", "r", encoding="utf-8").read(),
-    version = __meta__.__version__
-)
-
-env = Environment(
-    loader=PackageLoader("pygitweb", "templates"),
-    # autoescape=True,
-    trim_blocks=True,
-    lstrip_blocks=True,
+    version=__meta__.__version__,
 )
-PREAMBLE = env.get_template("preamble.html")
-POSTAMBLE = """</div></div></div></body></html>"""
 
 # Todo handle with nginx route
 _static_dir = Path(__file__).parent / "static"
@@ -100,11 +80,6 @@ def _project_in_list(project: str) -> bool:
     return any(p.get("path") == project for p in lst)
 
 
-def _get_project_config(project: str, key: str):
-    from pygitweb.git_helpers import git_get_project_config
-    return git_get_project_config(project, key)
-
-
 _auth_provider = None
 
 
@@ -154,7 +129,7 @@ def _request_can_add_project(request: Request) -> bool:
 def startup():
     evaluate_gitweb_config()
     configure_gitweb_features(
-        get_project_config=_get_project_config,
+        get_project_config=git_get_project_config,
         git_dir=None,
         is_valid_ref_format=is_valid_ref_format,
     )
@@ -382,40 +357,6 @@ async def addproject_submit(
 # ---------- Routes (project required) ----------
 
 
-def _pagination_url(request: Request, page: int, pagecount: int) -> str:
-    """Build URL for a pagination page, preserving path and other query params."""
-    params = dict(request.query_params)
-    params["page"] = str(page)
-    params["pagecount"] = str(pagecount)
-    return f"{request.url.path}?{urlencode(params)}"
-
-
-def _parse_pagination(
-    page: str | None,
-    pagecount: str | None,
-) -> tuple[int, int]:
-    """Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
-    p = 1
-    pc = 25
-    if page is not None:
-        try:
-            p = int(page)
-        except ValueError:
-            raise HTTPException(status_code=400, detail="page must be an integer")
-        if p < 1:
-            raise HTTPException(status_code=400, detail="page must be at least 1")
-    if pagecount is not None:
-        try:
-            pc = int(pagecount)
-        except ValueError:
-            raise HTTPException(status_code=400, detail="pagecount must be an integer")
-        if pc < 1:
-            raise HTTPException(status_code=400, detail="pagecount must be at least 1")
-        if pc > 50:
-            raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
-    return p, pc
-
-
 @app.get("/{project:path}", response_class=HTMLResponse)
 def dispatch(
     request: Request,
@@ -445,12 +386,12 @@ def dispatch(
     # If no action, infer: hash only -> object type; project only -> summary
     if not action:
         if hash_param and file_name:
-            obj_type = _object_type(proj, f"{hash_param}:{file_name}")
+            obj_type = git_get_type(proj, f"{hash_param}:{file_name}")
             if not obj_type:
                 raise HTTPException(status_code=404, detail="File or directory does not exist")
             action = "tree" if obj_type == "tree" else "blob_plain"
         elif hash_param:
-            obj_type = _object_type(proj, hash_param)
+            obj_type = git_get_type(proj, hash_param)
             if not obj_type:
                 raise HTTPException(status_code=404, detail="Object does not exist")
             action = {"commit": "commit", "tree": "tree", "blob": "blob", "tag": "tag"}.get(obj_type, "object")
@@ -472,18 +413,18 @@ def dispatch(
     if action == "blobpatch":
         return git_blobpatch(proj, h, hb, file_name, file_parent)
     if action == "log":
-        p, pc = _parse_pagination(page, pagecount)
+        p, pc = parse_pagination(page, pagecount)
         return git_log(proj, hash_param, request, p, pc)
     if action == "shortlog":
-        p, pc = _parse_pagination(page, pagecount)
+        p, pc = parse_pagination(page, pagecount)
         return git_shortlog(proj, hash_param, request, p, pc)
     if action == "history":
-        p, pc = _parse_pagination(page, pagecount)
+        p, pc = parse_pagination(page, pagecount)
         return git_history(proj, hash_param, file_name, request, p, pc)
     if action == "heads":
         return git_heads(proj)
     if action == "tags":
-        p, pc = _parse_pagination(page, pagecount)
+        p, pc = parse_pagination(page, pagecount)
         return git_tags(proj, request, p, pc)
     if action == "tag":
         return git_tag(proj, hash_param)
@@ -506,777 +447,6 @@ def dispatch(
     )
 
 
-def _object_type(project: str, ref: str) -> str | None:
-    from pygitweb.git_helpers import git_get_type
-    return git_get_type(project, ref)
-
-
-def git_object(project: str, h: str | None) -> Response:
-    """Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Object hash (h) required")
-    if not is_valid_ref_format(h):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    obj_type = _object_type(project, h)
-    if not obj_type:
-        raise HTTPException(status_code=404, detail="Object not found")
-    if obj_type == "commit":
-        return git_commit(project, h)
-    if obj_type == "tree":
-        return git_tree(project, h, None)
-    if obj_type == "tag":
-        return git_tag(project, h)
-    if obj_type == "blob":
-        try:
-            from pygitweb.git_helpers import open_repo
-            repo = open_repo(project)
-            obj = repo.revparse_single(h)
-        except (KeyError, pygit2.GitError, OSError):
-            raise HTTPException(status_code=404, detail="Object not found")
-        if not isinstance(obj, pygit2.Blob):
-            raise HTTPException(status_code=404, detail="Not a blob")
-        data = obj.data
-        text = to_utf8(data) or ""
-        body = esc_html(sanitize(text) or "") or ""
-        lines = text.split("\n")
-        num_lines = max(1, len(lines))
-        line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
-        oid_short = str(obj.id)[:7]
-        blob_html = (
-            f"<div class=\"blob-view\">"
-            f"<div class=\"blob-line-nums\" aria-hidden=\"true\">{esc_html(line_nums)}</div>"
-            f"<pre class=\"blob-content\"><code class=\"hljs\">{body}</code></pre>"
-            f"</div>"
-        )
-        pre = PREAMBLE.render(
-            title=f"Blob {oid_short} - {esc_html(project)}",
-            site_name=config.SITE_NAME,
-        )
-        return HTMLResponse(f"{pre}<h1>Blob {esc_html(oid_short)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{blob_html}{POSTAMBLE}")
-    raise HTTPException(status_code=404, detail="Unknown object type")
-
-
-# Common README filenames to look for (order matters: prefer README.md)
-README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]
-
-
-def _get_readme_at_ref_path(
-    project: str, ref: str | None, dir_path: str | None
-) -> tuple[str, str] | None:
-    """If a README exists at ref in the given tree (dir_path), return (filename, utf8_content). Otherwise None.
-    dir_path is the tree path (e.g. '' for root, 'docs' for docs/)."""
-    if not ref:
-        return None
-    for name in README_CANDIDATES:
-        path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
-        result = get_blob_at_ref_path(project, ref, path)
-        if result:
-            blob, _ = result
-            text = to_utf8(blob.data) or ""
-            return (name, text)
-    return None
-
-
-def _render_readme_card(
-    project: str,
-    ref_oid: str,
-    readme_filename: str,
-    readme_content: str,
-    blob_dir: str = "",
-) -> str:
-    """Return HTML for the README card (and script for markdown). blob_dir is the current tree path for relative links."""
-    is_markdown = readme_filename.lower().endswith(".md")
-    blob_base = f"/{project}?a=blob&h={quote(ref_oid, safe='')}&f="
-    blob_base_attr = blob_base.replace("&", "&amp;").replace('"', "&quot;")
-    blob_dir_attr = blob_dir.replace("&", "&amp;").replace('"', "&quot;") if blob_dir else ""
-    if is_markdown:
-        readme_b64 = base64.b64encode(readme_content.encode("utf-8")).decode("ascii")
-        card = (
-            '<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
-            '<div class="card-body"><div id="readme-container" class="markdown-body" data-readme-b64="'
-            + readme_b64
-            + '" data-blob-base="'
-            + blob_base_attr
-            + '"'
-        )
-        if blob_dir_attr:
-            card += ' data-blob-dir="' + blob_dir_attr + '"'
-        card += '></div></div></div><script src="/static/readme-render.js"></script>'
-        return card
-    return (
-        '<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
-        '<div class="card-body"><pre class="readme-plain"><code>'
-        + esc_html(sanitize(readme_content) or "")
-        + "</code></pre></div></div>"
-    )
-
-
-def git_summary(project: str) -> HTMLResponse:
-    """Project summary page. Port of git_summary."""
-    descr = git_get_project_description(project) or "none"
-    owner = git_get_project_owner(project) or ""
-    head = git_get_head_hash(project)
-    co = parse_commit(project, head) if head else {}
-    head_short = head[:7] if head else ""
-
-    table = env.get_template("table.html").render(
-        cols=["Field", "Value"],
-        rows=[
-            ["Description", esc_html(descr)],
-            ["Owner", esc_html(owner)],
-            [esc_html("HEAD"), f"<a href='/{project}?a=commit&h={quote(head or '', safe='')}'>{head_short or 'N/A'}</a>"],
-            [esc_html("tree"), f"<a href='/{project}?a=tree&h={head or ''}'>browse</a>"],
-            ["Log", f"<a href='/{project}?a=log&h={head or ''}'>view log</a>"],
-            ["Shortlog", f"<a href='/{project}?a=shortlog&h={head or ''}'>view shortlog</a>"],
-            ["Heads", f"<a href='/{project}?a=heads'>view heads</a>"],
-            ["Tags", f"<a href='/{project}?a=tags'>view tags</a>"],
-            ["Remotes", f"<a href='/{project}?a=remotes'>view remotes</a>"],
-        ]
-    )
-    pre = PREAMBLE.render(title=f"{esc_html(config.SITE_NAME)} - {project}", site_name=config.SITE_NAME)
-    body_parts = [f"{pre}<h1>{esc_html(project)}</h1>{table}"]
-
-    readme = _get_readme_at_ref_path(project, head, "")
-    if readme:
-        readme_filename, readme_content = readme
-        body_parts.append(_render_readme_card(project, head or "", readme_filename, readme_content, ""))
-
-    body_parts.append(POSTAMBLE)
-    return HTMLResponse("".join(body_parts))
-
-
-def git_remotes(project: str) -> HTMLResponse:
-    """Remotes page: list configured remotes (name, url, push_url)."""
-    remotes = git_get_remotes_info(project)
-    if not remotes:
-        pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
-        return HTMLResponse(
-            f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}"
-        )
-    rows = []
-    for r in remotes:
-        name = esc_html(r["name"])
-        url = esc_html(r["url"] or "—")
-        push_url = esc_html(r["push_url"] or "—")
-        rows.append([name, url, push_url])
-    table = env.get_template("table.html").render(
-        cols=["Name", "URL", "Push URL"],
-        rows=rows,
-    )
-    pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
-    return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
-
-
-def _tree_url(project: str, h: str, f: str | None, a: str = "tree") -> str:
-    """Build URL for tree or blob: /project?a=a&h=h&f=f with f quoted."""
-    q = f"a={a}&h={quote(h, safe='')}"
-    if f:
-        q += f"&f={quote(f, safe='/')}"
-    return f"/{project}?{q}"
-
-
-def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
-    """Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
-    if not f:
-        raise HTTPException(status_code=400, detail="File path (f) required")
-    if not is_valid_pathname(f):
-        raise HTTPException(status_code=400, detail="Invalid path")
-    result = get_blob_at_ref_path(project, h, f)
-    if not result:
-        raise HTTPException(status_code=404, detail="File not found")
-    blob, _ = result
-    data = blob.data
-    if raw:
-        media_type, _ = mimetypes.guess_type(f.split("/")[-1])
-        if media_type is None:
-            try:
-                data.decode("utf-8")
-                media_type = "text/plain; charset=utf-8"
-            except UnicodeDecodeError:
-                media_type = "application/octet-stream"
-        return Response(content=data, media_type=media_type)
-    # HTML view: raw content, escaped for safe display (so HTML/XML/SVG are not parsed as DOM)
-    text = to_utf8(data) or ""
-    body = esc_html(sanitize(text) or "") or ""
-    lines = text.split("\n")
-    num_lines = max(1, len(lines))
-    line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
-    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
-    lang = BLOB_LANG.get(ext, "")
-    lang_attr = f" language-{lang}" if lang else ""
-    blob_html = (
-        f"<div class=\"blob-view\">"
-        f"<div class=\"blob-line-nums\" aria-hidden=\"true\">{esc_html(line_nums)}</div>"
-        f"<pre class=\"blob-content\"><code class=\"hljs{lang_attr}\">{body}</code></pre>"
-        f"</div>"
-    )
-    blob_script = '<script src="static/blob-view.js"></script>'
-    pre = PREAMBLE.render(title=f"{esc_html(f)} - {esc_html(project)}", site_name=config.SITE_NAME)
-    return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
-
-
-def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
-    """Tree page: list files and directories; directories link to tree with f=path."""
-    if f is not None and not is_valid_pathname(f):
-        raise HTTPException(status_code=400, detail="Invalid path")
-    result = get_tree_at_ref_path(project, h, f)
-    if not result:
-        raise HTTPException(status_code=404, detail="Tree or path not found")
-    tree, ref_oid = result
-    # Breadcrumb: project -> path segments
-    base = f"/{project}"
-    breadcrumbs = [f'<a href="{base}">{esc_html(project)}</a>']
-    if f:
-        parts = f.strip("/").split("/")
-        for i, seg in enumerate(parts):
-            prefix = "/".join(parts[: i + 1])
-            breadcrumbs.append(
-                f' / <a href="{_tree_url(project, ref_oid, prefix)}">{esc_html(seg)}</a>'
-            )
-    breadcrumb_html = "".join(breadcrumbs)
-    # List entries: dirs first then files, sorted by name
-    entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
-    dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
-    blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
-    rows = []
-    for name, typ, _ in dirs:
-        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
-        link = _tree_url(project, ref_oid, sub_path)
-        rows.append(
-            [f'<a href="{link}">{esc_html(name)}/</a>', "tree"]
-        )
-    for name, typ, _ in blobs:
-        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
-        link = _tree_url(project, ref_oid, sub_path, a="blob")
-        rows.append(
-            [f'<a href="{link}">{esc_html(name)}</a>', "blob"]
-        )
-    title_path = f" / {f}" if f else ""
-    pre = PREAMBLE.render(title=f"{esc_html(project)}{esc_html(title_path)} - Tree", site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Name", "Type"],
-        rows=rows
-    )
-    body_parts = [
-        f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{esc_html(title_path)}</h1>{table}"
-    ]
-    readme = _get_readme_at_ref_path(project, ref_oid, f or "")
-    if readme:
-        readme_filename, readme_content = readme
-        body_parts.append(_render_readme_card(project, ref_oid, readme_filename, readme_content, f or ""))
-    body_parts.append(POSTAMBLE)
-    return HTMLResponse("".join(body_parts))
-
-
-def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
-    """Format epoch timestamp to readable date string.
-    tz_offset is in minutes (as returned by pygit2).
-    """
-    if epoch is None:
-        return ""
-    try:
-        # Create timezone-aware datetime
-        if tz_offset is not None:
-            # pygit2 offset is in minutes, convert to seconds for timedelta
-            tz = timezone(timedelta(seconds=tz_offset * 60))
-        else:
-            tz = timezone.utc
-        dt = datetime.fromtimestamp(epoch, tz=tz)
-        return dt.strftime("%Y-%m-%d %H:%M:%S")
-    except (ValueError, OSError):
-        return ""
-
-
-def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> str:
-    """Format a single commit as a table row."""
-    oid = commit.get("oid", "")
-    oid_short = oid[:7] if oid else ""
-    subject = commit.get("subject", "")
-    author = commit.get("author", "")
-    author_email = commit.get("author_email", "")
-    committer_epoch = commit.get("committer_epoch")
-    author_epoch = commit.get("author_epoch")
-    
-    # Format date
-    date_str = _format_date(author_epoch, commit.get("author_tz"))
-    age_sec = None
-    if author_epoch:
-        age_sec = datetime.now(timezone.utc).timestamp() - author_epoch
-        age_str = age_string(age_sec) if age_sec > 0 else "right now"
-    else:
-        age_str = ""
-    
-    commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
-    diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
-    author_display = esc_html(author) or "unknown"
-    
-    if short:
-        # Shortlog: simpler format
-        return [
-            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
-            esc_html(subject),
-            author_display,
-            esc_html(age_str),
-            f'<a href="{diff_link}">diff</a>',
-        ]
-    else:
-        # Full log: more details
-        return [
-            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
-            esc_html(subject),
-            author_display,
-            esc_html(date_str),
-            esc_html(age_str),
-            f'<a href="{diff_link}">diff</a>',
-        ]
-
-
-def _render_pagination(
-    request: Request,
-    page: int,
-    pagecount: int,
-    has_prev: bool,
-    has_next: bool,
-    total_pages: int | None = None,
-) -> str:
-    """Render Tabler pagination HTML."""
-    prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
-    next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
-    page_links = None
-    if total_pages is not None and total_pages <= 20:
-        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
-    elif total_pages is not None:
-        start = max(1, page - 2)
-        end = min(total_pages, page + 2)
-        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
-        if start > 1:
-            page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
-        if end < total_pages:
-            page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
-    return env.get_template("pagination.html").render(
-        current_page=page,
-        pagecount=pagecount,
-        has_prev=has_prev,
-        has_next=has_next,
-        prev_url=prev_url,
-        next_url=next_url,
-        total_pages=total_pages,
-        page_links=page_links,
-    )
-
-
-def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
-    """Commit log page. Port of git_log."""
-    skip = (page - 1) * pagecount
-    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
-    if not commits:
-        raise HTTPException(status_code=404, detail="No commits found")
-    has_next = len(commits) > pagecount
-    if has_next:
-        commits = commits[:pagecount]
-    rows = []
-    for commit in commits:
-        rows.append(_format_commit_table_row(project, commit, short=False))
-    ref_display = h[:7] if h else "HEAD"
-    title = f"Log - {esc_html(project)} @ {esc_html(ref_display)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
-        rows=rows
-    )
-    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-    return HTMLResponse(
-        f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}"
-    )
-
-
-def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
-    """Shortlog page. Port of git_shortlog."""
-    skip = (page - 1) * pagecount
-    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
-    if not commits:
-        raise HTTPException(status_code=404, detail="No commits found")
-    has_next = len(commits) > pagecount
-    if has_next:
-        commits = commits[:pagecount]
-    rows = []
-    for commit in commits:
-        rows.append(_format_commit_table_row(project, commit, short=True))
-    ref_display = h[:7] if h else "HEAD"
-    title = f"Shortlog - {esc_html(project)} @ {esc_html(ref_display)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Age", "Diff"],
-        rows=rows
-    )
-    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-    return HTMLResponse(
-        f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}"
-    )
-
-
-def git_history(project: str, h: str | None, f: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
-    """History page for a file or directory. Port of git_history."""
-    if not f:
-        raise HTTPException(status_code=400, detail="File path (f) required for history")
-    if not is_valid_pathname(f):
-        raise HTTPException(status_code=400, detail="Invalid path")
-    skip = (page - 1) * pagecount
-    commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
-    if not commits:
-        raise HTTPException(status_code=404, detail="No history found for this path")
-    has_next = len(commits) > pagecount
-    if has_next:
-        commits = commits[:pagecount]
-    rows = []
-    for commit in commits:
-        rows.append(_format_commit_table_row(project, commit, short=False))
-    ref_display = h[:7] if h else "HEAD"
-    title = f"History - {esc_html(f)} - {esc_html(project)} @ {esc_html(ref_display)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
-        rows=rows
-    )
-    pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-    return HTMLResponse(
-        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{pagination_html}{POSTAMBLE}"
-    )
-
-
-def git_heads(project: str) -> HTMLResponse:
-    """Heads (branches) list page. Port of git_heads."""
-    heads_list = git_get_heads_list(project)
-    if not heads_list:
-        raise HTTPException(status_code=404, detail="No heads found")
-    rows = []
-    for name, _ref, oid in heads_list:
-        oid_short = oid[:7] if oid else ""
-        commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
-        tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
-        rows.append([
-            f'<a href="{commit_link}">{esc_html(name)}</a>',
-            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
-            f'<a href="{tree_link}">tree</a>',
-        ])
-    title = f"Heads - {esc_html(project)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Head", "Commit", ""],
-        rows=rows,
-    )
-    return HTMLResponse(
-        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
-    )
-
-
-def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
-    """Tags list page. Port of git_tags."""
-    tags_list = git_get_tags_list(project)
-    if not tags_list:
-        raise HTTPException(status_code=404, detail="No tags found")
-    total = len(tags_list)
-    total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
-    if page > total_pages and total > 0:
-        raise HTTPException(status_code=404, detail="Page does not exist")
-    skip = (page - 1) * pagecount
-    tags_page = tags_list[skip : skip + pagecount]
-    try:
-        from pygitweb.git_helpers import open_repo
-        repo = open_repo(project)
-    except Exception:
-        raise HTTPException(status_code=404, detail="Repository not found")
-    rows = []
-    for name, _ref, oid in tags_page:
-        tag_link = f"/{project}?a=tag&h={quote(oid, safe='')}"
-        target_oid = oid
-        target_type = "commit"
-        try:
-            obj = repo.revparse_single(oid)
-            if isinstance(obj, pygit2.Tag):
-                target_oid = str(obj.target)
-                target_type = obj.type_str
-        except (KeyError, pygit2.GitError):
-            pass
-        target_short = target_oid[:7] if target_oid else ""
-        if target_type == "commit":
-            target_link = f"/{project}?a=commit&h={quote(target_oid, safe='')}"
-        elif target_type == "tree":
-            target_link = f"/{project}?a=tree&h={quote(target_oid, safe='')}"
-        else:
-            target_link = None
-        obj_cell = f'<a href="{target_link}">{esc_html(target_short)}</a>' if target_link else esc_html(target_short)
-        rows.append([
-            f'<a href="{tag_link}">{esc_html(name)}</a>',
-            obj_cell,
-        ])
-    title = f"Tags - {esc_html(project)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    table = env.get_template("table.html").render(
-        cols=["Tag", "Object"],
-        rows=rows,
-    )
-    pagination_html = _render_pagination(
-        request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages
-    )
-    return HTMLResponse(
-        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{pagination_html}{POSTAMBLE}"
-    )
-
-
-def git_tag(project: str, h: str | None) -> HTMLResponse:
-    """Single tag view. Port of git_tag."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
-    if not is_valid_ref_format(h):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    try:
-        from pygitweb.git_helpers import open_repo
-        repo = open_repo(project)
-        obj = repo.revparse_single(h)
-    except (KeyError, pygit2.GitError, OSError):
-        raise HTTPException(status_code=404, detail="Tag or object not found")
-    if not isinstance(obj, pygit2.Tag):
-        raise HTTPException(status_code=404, detail="Not a tag object")
-    tag_oid = str(obj.id)
-    tag_data = parse_tag(project, tag_oid)
-    if not tag_data:
-        raise HTTPException(status_code=404, detail="Tag not found")
-    target_oid = tag_data.get("object", "")
-    target_type = tag_data.get("type", "commit")
-    tagger = tag_data.get("tagger", "")
-    tagger_epoch = tag_data.get("tagger_epoch")
-    tagger_tz = tag_data.get("tagger_tz")
-    message = (tag_data.get("message") or "").strip()
-    tag_name = None
-    for name, _ref, oid in git_get_tags_list(project):
-        if oid == tag_oid:
-            tag_name = name
-            break
-    if tag_name is None:
-        tag_name = tag_oid[:7]
-    target_short = target_oid[:7] if target_oid else ""
-    if target_type == "commit":
-        object_link = f'<a href="/{project}?a=commit&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
-    elif target_type == "tree":
-        object_link = f'<a href="/{project}?a=tree&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
-    else:
-        object_link = esc_html(target_short)
-    table_rows = [
-        ["Tag", esc_html(tag_name)],
-        ["Object", f"{object_link} ({esc_html(target_type)})"],
-        ["Tagger", esc_html(tagger)],
-        ["Date", esc_html(_format_date(tagger_epoch, tagger_tz))],
-    ]
-    if message:
-        table_rows.append(["Message", f"<pre class='tag-message'>{esc_html(message)}</pre>"])
-    table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-    title = f"Tag {esc_html(tag_name)} - {esc_html(project)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    return HTMLResponse(
-        f"{pre}<h1>Tag {esc_html(tag_name)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
-    )
-
-
-# Well-known empty tree OID (Git standard)
-_EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
-
-
-def git_commit(project: str, h: str | None) -> HTMLResponse:
-    """Single commit view. Port of git_commit (git show)."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Commit hash (h) required")
-    if not is_valid_ref_format(h):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    try:
-        from pygitweb.git_helpers import open_repo
-        repo = open_repo(project)
-        obj = repo.revparse_single(h)
-    except (KeyError, pygit2.GitError, OSError):
-        raise HTTPException(status_code=404, detail="Commit not found")
-    if not isinstance(obj, pygit2.Commit):
-        raise HTTPException(status_code=404, detail="Not a commit object")
-    commit = obj
-    oid = str(commit.id)
-    oid_short = oid[:7]
-    data = parse_commit(project, oid)
-    author = data.get("author", "")
-    author_email = data.get("author_email", "")
-    author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
-    tree_oid = data.get("tree", "")
-    message = (data.get("body") or "").strip()
-    commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
-    tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
-    diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
-    patch_link = f"/{project}?a=patch&h={quote(oid, safe='')}"
-    table_rows = [
-        ["commit", f'<a href="{commit_link}">{esc_html(oid)}</a>'],
-        ["Author", f"{esc_html(author)} &lt;{esc_html(author_email)}&gt;"],
-        ["Date", esc_html(author_date)],
-        ["tree", f'<a href="{tree_link}">{esc_html(tree_oid[:7])}</a>'],
-    ]
-    for i, parent_id in enumerate(commit.parent_ids):
-        parent_oid = str(parent_id)
-        parent_short = parent_oid[:7]
-        parent_link = f"/{project}?a=commit&h={quote(parent_oid, safe='')}"
-        label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
-        table_rows.append([label, f'<a href="{parent_link}">{esc_html(parent_short)}</a>'])
-    if message:
-        table_rows.append(["", f"<pre class='commit-message'>{esc_html(message)}</pre>"])
-    table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
-    table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-    title = f"Commit {esc_html(oid_short)} - {esc_html(project)}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    return HTMLResponse(
-        f"{pre}<h1>Commit {esc_html(oid_short)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
-    )
-
-
-def _get_commit_unified_diff(project: str, h: str) -> tuple[str, str]:
-    """Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Commit hash (h) required")
-    if not is_valid_ref_format(h):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    try:
-        from pygitweb.git_helpers import open_repo
-        repo = open_repo(project)
-        commit = repo.revparse_single(h)
-    except (KeyError, pygit2.GitError, OSError):
-        raise HTTPException(status_code=404, detail="Commit not found")
-    if not isinstance(commit, pygit2.Commit):
-        raise HTTPException(status_code=404, detail="Not a commit object")
-    if commit.parents:
-        diff = repo.diff(commit.parents[0], commit)
-    else:
-        empty_tree = repo.revparse_single(_EMPTY_TREE_OID)
-        diff = repo.diff(empty_tree, commit.tree)
-    parts: list[str] = []
-    for patch in diff:
-        if patch.text:
-            parts.append(patch.text)
-    body = "".join(parts) if parts else ""
-    oid_short = str(commit.id)[:7]
-    return body, oid_short
-
-
-def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
-    """Commit diff page: unified diff rendered with diff2html (script id diff2html-script)."""
-    diff_text, oid_short = _get_commit_unified_diff(project, h or "")
-    # Base64-encode diff so we can embed safely in HTML without breaking script tags
-    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-    title = f"Commit diff {oid_short} - {project}"
-    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
-    body = env.get_template("commitdiff.html").render(
-        project=project,
-        oid_short=oid_short,
-        diff_b64=diff_b64,
-        commit_link=f"/{project}?a=commit&h={quote(h or '', safe='')}",
-    )
-    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
-
-
-def git_patch(project: str, h: str | None) -> PlainTextResponse:
-    """Single-commit patch (plain text unified diff)."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Commit hash (h) required")
-    body, oid_short = _get_commit_unified_diff(project, h)
-    filename = f"{project}-{oid_short}.patch"
-    return PlainTextResponse(
-        body,
-        media_type="text/x-diff; charset=utf-8",
-        headers={"Content-Disposition": f'inline; filename="{filename}"'},
-    )
-
-
-def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
-    """Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
-    if not h:
-        raise HTTPException(status_code=400, detail="Commit hash (h) required")
-    if hb:
-        oids = get_commits_in_range(project, h, hb)
-        if not oids:
-            raise HTTPException(status_code=404, detail="No commits in range")
-        parts = []
-        for oid in oids:
-            diff, _ = _get_commit_unified_diff(project, oid)
-            if diff:
-                parts.append(diff)
-        body = "\n".join(parts)
-        filename = f"{project}-{h[:7]}-patches.patch"
-    else:
-        body, oid_short = _get_commit_unified_diff(project, h)
-        filename = f"{project}-{oid_short}.patch"
-    return PlainTextResponse(
-        body,
-        media_type="text/x-diff; charset=utf-8",
-        headers={"Content-Disposition": f'inline; filename="{filename}"'},
-    )
-
-
-def git_blobdiff(
-    project: str,
-    h: str | None,
-    hb: str | None,
-    f: str | None,
-    fp: str | None,
-) -> HTMLResponse:
-    """Blob diff page: diff between two blob versions, rendered with diff2html."""
-    if not h or not hb:
-        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
-    if not f:
-        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
-    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    path_new = f or ""
-    path_old = fp if fp is not None else path_new
-    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
-        raise HTTPException(status_code=400, detail="Invalid path")
-    diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
-    if diff_text is None:
-        raise HTTPException(status_code=404, detail="Blob not found")
-    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-    blob_link = f"/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
-    pre = PREAMBLE.render(
-        title=f"Blob diff - {esc_html(path_new)} - {esc_html(project)}",
-        site_name=config.SITE_NAME,
-    )
-    body = env.get_template("blobdiff.html").render(
-        project=project,
-        path_new=path_new,
-        diff_b64=diff_b64,
-        blob_link=blob_link,
-    )
-    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
-
-
-def git_blobpatch(
-    project: str,
-    h: str | None,
-    hb: str | None,
-    f: str | None,
-    fp: str | None,
-) -> PlainTextResponse:
-    """Blob diff as plain unified diff."""
-    if not h or not hb:
-        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
-    if not f:
-        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
-    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
-        raise HTTPException(status_code=400, detail="Invalid ref or hash")
-    path_new = f or ""
-    path_old = fp if fp is not None else path_new
-    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
-        raise HTTPException(status_code=400, detail="Invalid path")
-    body = get_blob_unified_diff(project, hb, path_old, h, path_new)
-    if body is None:
-        raise HTTPException(status_code=404, detail="Blob not found")
-    filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
-    return PlainTextResponse(
-        body,
-        media_type="text/x-diff; charset=utf-8",
-        headers={"Content-Disposition": f'inline; filename="{filename}"'},
-    )
 
 
 if __name__ == "__main__":
diff --git a/pygitweb/templates_env.py b/pygitweb/templates_env.py
new file mode 100644
index 0000000..0e26006
--- /dev/null
+++ b/pygitweb/templates_env.py
@@ -0,0 +1,10 @@
+"""Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
+from jinja2 import Environment, PackageLoader
+
+env = Environment(
+    loader=PackageLoader("pygitweb", "templates"),
+    trim_blocks=True,
+    lstrip_blocks=True,
+)
+PREAMBLE = env.get_template("preamble.html")
+POSTAMBLE = """</div></div></div></body></html>"""
diff --git a/pygitweb/validation.py b/pygitweb/validation.py
index 9f06b40..129e4f1 100644
--- a/pygitweb/validation.py
+++ b/pygitweb/validation.py
@@ -78,7 +78,6 @@ def check_export_ok(
     if not os.path.isdir(git_dir):
         return False
     try:
-        print(git_dir)
         discovered = pygit2.discover_repository(git_dir, across_fs)
         if not discovered:
             return False
