b84f4ef 997da40 b84f4ef 147709a b84f4ef 997da40 de79289 65ca31f 215041e b84f4ef 7377412 147709a de79289 b84f4ef 26f0919 69644d2 b84f4ef 73c9c30 b84f4ef c532667 147709a 73c9c30 147709a b84f4ef 760fe33 73c9c30 760fe33 b84f4ef 267233d 73c9c30 267233d de79289 73c9c30 de79289 73c9c30 de79289 73c9c30 de79289 73c9c30 de79289 65ca31f 73c9c30 65ca31f de79289 73c9c30 de79289 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 760fe33 73c9c30 b84f4ef 00e5b5b 73c9c30 00e5b5b 73c9c30 d38adc1 73c9c30 d38adc1 00e5b5b 73c9c30 00e5b5b 73c9c30 00e5b5b b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef bb40e9d 73c9c30 bb40e9d 7377412 73c9c30 7377412 bb40e9d 73c9c30 bb40e9d 73c9c30 bb40e9d 73c9c30 bb40e9d b84f4ef 73c9c30 b84f4ef 215041e 73c9c30 215041e 73c9c30 de79289 73c9c30 de79289 00e5b5b 73c9c30 dfe0870 147709a 73c9c30 147709a 73c9c30 147709a 73c9c30 147709a 00e5b5b 73c9c30 723996c 760fe33 73c9c30 760fe33 83f6159 73c9c30 83f6159 00e5b5b 73c9c30 00e5b5b 83f6159 73c9c30 83f6159 00e5b5b 73c9c30 00e5b5b 83f6159 73c9c30 83f6159 760fe33 73c9c30 760fe33 73c9c30 760fe33 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 83f6159 73c9c30 83f6159 267233d 73c9c30 267233d 73c9c30 267233d 73c9c30 267233d 00e5b5b 73c9c30 267233d 73c9c30 267233d 73c9c30 267233d b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 997da40 73c9c30 997da40 73c9c30 b84f4ef 3abcfc4 73c9c30 b84f4ef 73c9c30 b84f4ef 7377412 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30 b84f4ef 73c9c30
"""
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 json
import mimetypes
import os
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal, TypedDict
from urllib.parse import parse_qsl, quote, urlencode

import pygit2
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
from pygittools.merge import MR_REF_PREFIX, MergeRequestStatus, get_merge_request_by_oid
from python_ripgrep import PySortMode, PySortModeKind
from python_ripgrep import search as rg_search

from pygitweb.config import BLOB_LANG, settings
from pygitweb.formatting import age_string, 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_heads_list,
    git_get_project_description,
    git_get_references,
    git_get_remotes_info,
    git_get_tags_list,
    git_get_type,
    open_repo,
    parse_commit,
    parse_tag,
)
from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
from pygitweb.merge_requests import (
    merge_request_status_fields,
    merge_request_tag_response,
    resolve_merge_request_tag_to_tip,
)
from pygitweb.projects import git_get_project_owner
from pygitweb.templates_env import (
    Cell,
    env,
    html_format,
    html_join,
    html_link,
    render_page,
    trusted_html,
)
from pygitweb.validation import is_valid_pathname, is_valid_ref_format


class BlameHunkInfo(TypedDict):
    commit_id: str
    commit_msg: str
    commit_author: str
    time: str
    line_start: int
    line_end: int
    author_name: str
    time_display: str


def _split_query_list(values: list[str]) -> list[str]:
    parts: list[str] = []
    for v in values:
        for seg in (v or "").split(","):
            s = seg.strip()
            if s:
                parts.append(s)
    return parts


def _parse_query_bool(value: str | None) -> bool | None:
    if value is None:
        return None
    v = value.strip().lower()
    if v in ("1", "true", "yes", "on"):
        return True
    if v in ("0", "false", "no", "off"):
        return False
    raise HTTPException(status_code=400, detail=f"Invalid boolean: {value!r}")


def _project_worktree_root(project: str) -> Path:
    root = (Path(settings.PROJECTROOT) / project).resolve()
    if not root.exists() or not root.is_dir():
        raise HTTPException(status_code=404, detail="Project directory not found")
    return root


def _validated_search_paths(project_root: Path, raw_paths: list[str]) -> list[str]:
    root = project_root.resolve()
    if not raw_paths:
        return [str(root)]

    paths: list[str] = []
    for raw in raw_paths:
        p = (raw or "").strip()
        if not p:
            continue
        candidate = Path(p)
        if candidate.is_absolute() or ":" in p:
            raise HTTPException(status_code=400, detail="path not relative to the project root")
        abs_path = (root / p).resolve()
        if not abs_path.is_relative_to(root):
            raise HTTPException(status_code=400, detail="path not within the project root")
        if not abs_path.exists():
            raise HTTPException(status_code=400, detail=f"path entry does not exist: {p}")
        paths.append(str(abs_path))

    if not paths:
        return [str(root)]
    return paths


_SORT_KINDS: tuple[str, ...] = ("Path", "LastModified", "LastAccessed", "Created")

SearchSortQuery = Literal[
    "Path",
    "-Path",
    "LastModified",
    "-LastModified",
    "LastAccessed",
    "-LastAccessed",
    "Created",
    "-Created",
]

SearchFlagQuery = Literal["true", "false"]


def _parse_search_sort(value: str | None) -> PySortMode | None:
    if value is None or not value.strip():
        return None
    raw = value.strip()
    reverse = False
    if raw.startswith("-"):
        reverse = True
        raw = raw[1:].strip()
    if raw not in _SORT_KINDS:
        allowed = ", ".join(_SORT_KINDS)
        raise HTTPException(status_code=400, detail=f"Invalid sort kind; allowed: {allowed}")
    kind = getattr(PySortModeKind, raw)
    return PySortMode(kind=kind, reverse=reverse)


def _pagination_url(request: Request, page: int, pagecount: int) -> str:
    """Build URL for a pagination page, preserving path and other query params."""
    params = dict(parse_qsl(request.url.query, keep_blank_values=True))
    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 as e:
            raise HTTPException(status_code=400, detail="page must be an integer") from e
        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 as e:
            raise HTTPException(status_code=400, detail="pagecount must be an integer") from e
        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/{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/{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:
        tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else 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[Cell]:
    """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(UTC).timestamp() - author_epoch
        age_str = age_string(age_sec) if age_sec > 0 else "right now"
    else:
        age_str = ""

    commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
    diff_href = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
    author_display = author or "unknown"
    commit_cell = html_link(commit_href, oid_short)
    diff_cell = html_link(diff_href, "diff")

    if short:
        return [commit_cell, subject, author_display, age_str, diff_cell]
    return [commit_cell, subject, author_display, date_str, age_str, diff_cell]


def _pagination_context(
    request: Request,
    page: int,
    pagecount: int,
    has_prev: bool,
    has_next: bool,
    total_pages: int | None = None,
) -> dict[str, Any]:
    """Context for 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 {
        "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_tree_table(project: str, ref: str | None, f: str | None) -> str:
    """Return an HTML table listing files and directories at ref/path."""
    if f is not None and not is_valid_pathname(f):
        return ""
    result = get_tree_at_ref_path(project, ref, f)
    if not result:
        return '<p class="text-muted mb-0">No files found.</p>'
    tree, ref_oid = result
    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: list[list[Cell]] = []
    for name, _, _ in dirs:
        if name is None:
            continue
        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
        href = _tree_url(project, ref_oid, sub_path)
        rows.append([html_link(href, f"{name}/"), "tree", ""])
    for name, _, _ in blobs:
        if name is None:
            continue
        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
        href = _tree_url(project, ref_oid, sub_path, a="blob")
        blame_href = _tree_url(project, ref_oid, sub_path, a="blame")
        rows.append([
            html_link(href, name),
            "blob",
            html_link(blame_href, "blame"),
        ])
    if not rows:
        return '<p class="text-muted mb-0">Empty directory.</p>'
    return env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)


def _table_page(
    *,
    title: str,
    heading: str | None = None,
    cols: list[str] | None = None,
    rows: list[list[Cell]] | None = None,
    project: str | None = None,
    pagination: dict[str, Any] | None = None,
    empty_message: str | None = None,
    empty_muted: bool = False,
    nav: str | None = None,
) -> HTMLResponse:
    ctx: dict[str, object] = {
        "title": title,
        "heading": title if heading is None else heading,
        "project": project,
        "pagination": pagination,
        "empty_message": empty_message,
        "empty_muted": empty_muted,
        "nav": nav or ("projects" if project else None),
    }
    if cols is not None:
        ctx["cols"] = cols
        ctx["rows"] = rows or []
    return render_page("table_page.html", **ctx)


def _render_readme_card(
    project: str,
    ref_oid: str,
    readme_filename: str,
    readme_content: str,
    blob_dir: str = "",
) -> str:
    """Return HTML for the README section (and script for markdown).

    blob_dir is the current tree path for relative links.
    """
    is_markdown = readme_filename.lower().endswith(".md")
    if is_markdown:
        return env.get_template("readme_card.html").render(
            markdown=True,
            readme_b64=base64.b64encode(readme_content.encode("utf-8")).decode("ascii"),
            blob_base=f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f=",
            blob_dir=blob_dir,
        )
    return env.get_template("readme_card.html").render(
        markdown=False,
        plain=sanitize(readme_content) or "",
    )


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) as e:
        raise HTTPException(status_code=404, detail="Commit not found") from e


class SummaryRefOption(TypedDict):
    label: str
    value: str
    kind: str


class SummaryRefsPayload(TypedDict):
    options: list[SummaryRefOption]


class SummaryRefState(TypedDict):
    ref: str
    display: str
    commit_href: str
    browse_href: str
    log_href: str
    shortlog_href: str
    tree_html: str
    readme_html: str


def summary_ref_options(project: str) -> list[SummaryRefOption]:
    options: list[SummaryRefOption] = []
    for name, ref, _oid in git_get_heads_list(project):
        options.append({"label": name, "value": ref, "kind": "branch"})
    for name, ref, _oid in git_get_tags_list(project):
        options.append({"label": name, "value": ref, "kind": "tag"})
    return options


def summary_ref_state(project: str, requested_ref: str | None) -> SummaryRefState:
    ref = requested_ref or "HEAD"
    commit_oid = ""
    browse_ref = ref
    try:
        repo = open_repo(project)
        obj = repo.revparse_single(ref)
    except (KeyError, pygit2.GitError, OSError):
        ref = "HEAD"
        try:
            repo = open_repo(project)
            obj = repo.revparse_single(ref)
        except (KeyError, pygit2.GitError, OSError):
            obj = None
    if isinstance(obj, pygit2.Commit):
        commit_oid = str(obj.id)
    elif isinstance(obj, pygit2.Tag):
        try:
            target_obj = repo[obj.target]
        except (KeyError, pygit2.GitError):
            target_obj = None
        if isinstance(target_obj, pygit2.Commit):
            commit_oid = str(target_obj.id)
        elif target_obj is not None:
            browse_ref = str(target_obj.id)
    tree_html = _render_tree_table(project, ref, None)
    readme_html = ""
    readme = get_readme_at_ref_path(project, ref, "")
    if readme:
        readme_filename, readme_content = readme
        readme_html = _render_readme_card(project, ref, readme_filename, readme_content, "")
    log_ref = commit_oid or ref
    return {
        "ref": ref,
        "display": (commit_oid[:7] if commit_oid else ref) or "N/A",
        "commit_href": f"/project/{project}?a=commit&h={quote(commit_oid or ref, safe='')}",
        "browse_href": f"/project/{project}?a=tree&h={quote(browse_ref, safe='')}",
        "log_href": f"/project/{project}?a=log&h={quote(log_ref, safe='')}",
        "shortlog_href": f"/project/{project}?a=shortlog&h={quote(log_ref, safe='')}",
        "tree_html": tree_html,
        "readme_html": readme_html,
    }


# ---------- 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) as e:
            raise HTTPException(status_code=404, detail="Object not found") from e
        if not isinstance(obj, pygit2.Blob):
            raise HTTPException(status_code=404, detail="Not a blob")
        data = obj.data
        text = to_utf8(data) or ""
        lines = text.split("\n")
        num_lines = max(1, len(lines))
        oid_short = str(obj.id)[:7]
        return render_page(
            "blob.html",
            title=f"Blob {oid_short} - {project}",
            heading=f"Blob {oid_short}",
            project=project,
            code=sanitize(text) or "",
            line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
            lang="",
            blame=False,
        )
    raise HTTPException(status_code=404, detail="Unknown object type")


def git_search(
    project: str,
    search_patterns: str | None,
    search_paths: str | None,
    search_globs: str | None,
    search_heading: SearchFlagQuery | None,
    search_multiline: SearchFlagQuery | None,
    search_sort: SearchSortQuery | None,
    search_max_count: str | None,
) -> JSONResponse:
    patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
    if not patterns:
        raise HTTPException(status_code=400, detail="patterns is required (repeat ?patterns= or use comma-separated)")

    project_root = _project_worktree_root(project)
    paths = _validated_search_paths(project_root, _split_query_list([search_paths] if search_paths is not None else []))

    globs = _split_query_list([search_globs] if search_globs is not None else [])
    heading = _parse_query_bool(search_heading)
    multiline = _parse_query_bool(search_multiline)
    sort = _parse_search_sort(search_sort)

    max_count: int = 25
    if search_max_count is not None and search_max_count.strip() != "":
        try:
            max_count = int(search_max_count)
        except ValueError as e:
            raise HTTPException(status_code=400, detail="max_count must be an integer") from e
        if max_count < 1:
            raise HTTPException(status_code=400, detail="max_count must be at least 1")

    try:
        results = rg_search(
            patterns=patterns,
            paths=paths,
            globs=(globs or None),
            heading=heading,
            sort=sort,
            max_count=max_count,
            multiline=multiline,
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Search failed: {e}") from e

    root_str = str(project_root.resolve())
    rel_paths: list[str] = []
    for p in paths:
        abs_p = str(Path(p).resolve())
        rel = os.path.relpath(abs_p, root_str)
        rel_paths.append("" if rel == "." else rel.replace(os.sep, "/"))

    return JSONResponse({
        "project": project,
        "project_root": root_str,
        "patterns": patterns,
        "paths": rel_paths,
        "globs": globs,
        "heading": heading,
        "sort": (search_sort or None),
        "max_count": max_count,
        "multiline": multiline,
        "results": results,
    })


def git_search_page(project: str) -> HTMLResponse:
    """Per-project search UI page. Calls /project/{project}?a=search via fetch."""
    project_enc = quote(project, safe="/")
    return render_page(
        "search.html",
        title=f"Search - {project}",
        project=project,
        project_enc=project_enc,
        project_url=f"/project/{project_enc}",
        search_api_url=f"/project/{project_enc}",
    )


def _hooks_context(project: str, project_enc: str) -> dict[str, Any]:
    """Data for hooks_summary_cell.html."""
    try:
        installed = list_installed_hooks(project)
        available = list_installable_hooks(project)
    except (KeyError, OSError):
        return {"mode": "unavailable", "project_enc": project_enc, "installed": [], "available": []}
    return {
        "mode": "manage",
        "project_enc": project_enc,
        "installed": installed,
        "available": available,
    }


_OPEN_MR_BADGE = {
    "can_fast_forward": "green",
    "blocked_diverged": "yellow",
    "ours_ref_missing": "red",
    "mr_head_missing": "red",
    "error": "red",
}


def _ref_short(ref: str | None) -> str:
    if not ref:
        return ""
    if ref.startswith("refs/heads/"):
        return ref.removeprefix("refs/heads/")
    return ref


def _open_merge_requests(project: str) -> list[dict[str, str]]:
    """Merge requests whose head is not already contained in the destination branch."""
    try:
        repo = open_repo(project)
    except (OSError, pygit2.GitError, ValueError):
        return []
    project_enc = quote(project, safe="/")
    open_mrs: list[dict[str, str]] = []
    for ref_name in repo.references:
        if not ref_name.startswith(MR_REF_PREFIX):
            continue
        try:
            oid = repo.references[ref_name].resolve().target
            obj = repo[oid]
            if not isinstance(obj, pygit2.Tag):
                continue
            mr = get_merge_request_by_oid(repo, pygit2.Oid(hex=str(oid)))
        except (ValueError, KeyError, TypeError, json.JSONDecodeError, pygit2.GitError):
            continue
        try:
            status = mr.check_status(repo)
        except ValueError:
            status = None
        if status == MergeRequestStatus.UP_TO_DATE:
            continue
        fields = merge_request_status_fields(repo, mr)
        status_code = str(fields["status_code"])
        open_mrs.append({
            "title": mr.title or ref_name.removeprefix(MR_REF_PREFIX),
            "href": f"/project/{project_enc}?a=tag&h={quote(ref_name, safe='')}",
            "theirs": _ref_short(mr.theirs),
            "ours": _ref_short(mr.ours),
            "status_label": str(fields["status_label"]),
            "badge": _OPEN_MR_BADGE.get(status_code, "secondary"),
        })
    open_mrs.sort(key=lambda item: item["title"].lower())
    return open_mrs


def _merge_request_context(project: str) -> dict[str, Any] | None:
    heads = git_get_heads_list(project)
    if not heads:
        return None
    refs = [h[1] for h in heads]
    branches = [{"short": h[0], "ref": h[1]} for h in heads]
    if "refs/heads/main" in refs:
        ours_default_ref = "refs/heads/main"
    elif "refs/heads/master" in refs:
        ours_default_ref = "refs/heads/master"
    else:
        ours_default_ref = refs[0]
    theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
    return {
        "project": project,
        "branches": branches,
        "theirs_default_ref": theirs_default_ref,
        "ours_default_ref": ours_default_ref,
    }


def _project_has_boards(project: str) -> bool:
    try:
        return len(git_get_references(project, "refs/tags/boards")) > 0
    except Exception:
        return False


BLOG_MANIFEST_PATH: str = "articles/manifest.json"


def project_has_blog(project: str) -> bool:
    try:
        return get_blob_at_ref_path(project, "HEAD", BLOG_MANIFEST_PATH) is not None
    except Exception:
        return False


class BoardLink(TypedDict):
    href: str
    text: str
    style: str | None


class BlogLink(TypedDict):
    href: str
    text: str
    style: str | None


def board_link_spec(project: str) -> BoardLink:
    if _project_has_boards(project):
        return {
            "href": f"/project/{quote(project, safe='/')}/board/",
            "text": "project board",
            "style": None,
        }
    style = "color: #999; cursor: not-allowed;" if settings.AUTH else None
    return {
        "href": "/board/create?project=" + quote(project, safe=""),
        "text": "create board",
        "style": style,
    }


def blog_link_spec(project: str) -> BlogLink:
    if project_has_blog(project):
        return {
            "href": f"/project/{quote(project, safe='/')}/blog/",
            "text": "view blog",
            "style": None,
        }
    return {
        "href": "#",
        "text": "no blog",
        "style": "color: #999; cursor: not-allowed;",
    }


def create_board_link(project: str) -> Cell:
    href = "/board/create?project=" + quote(project, safe="")
    if settings.AUTH:
        return html_link(href, "create board", style="color: #999; cursor: not-allowed;")
    return html_link(href, "create board")


def project_board_link(project: str) -> Cell:
    spec = board_link_spec(project)
    if spec["style"]:
        return html_link(spec["href"], spec["text"], style=spec["style"])
    return html_link(spec["href"], spec["text"])


def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTMLResponse:
    """Project summary page. Port of git_summary."""
    descr = git_get_project_description(project) or ""
    owner = git_get_project_owner(project) or ""
    initial_ref_state = summary_ref_state(project, "HEAD")
    project_enc = quote(project, safe="/")
    ref_view: dict[str, object] = dict(initial_ref_state)
    ref_view["tree_html"] = trusted_html(initial_ref_state["tree_html"])
    ref_view["readme_html"] = trusted_html(initial_ref_state["readme_html"])
    return render_page(
        "summary.html",
        title=f"{settings.SITE_NAME} - {project}",
        project=project,
        descr=descr,
        owner=owner,
        ref=ref_view,
        project_url=f"/project/{project}",
        project_url_enc=f"/project/{project_enc}",
        board=board_link_spec(project),
        blog=blog_link_spec(project),
        hooks=_hooks_context(project, project_enc),
        merge_request=_merge_request_context(project),
        open_merge_requests=_open_merge_requests(project),
        extra_rows=extra_rows or [],
        nav="projects",
    )


def git_remotes(project: str) -> HTMLResponse:
    """Remotes page: list configured remotes (name, url, push_url)."""
    remotes = git_get_remotes_info(project)
    title = f"Remotes - {project}"
    if not remotes:
        return _table_page(title=title, heading="Remotes", empty_message="No remotes configured.")
    rows = [[r["name"], r["url"] or "—", r["push_url"] or "—"] for r in remotes]
    return _table_page(title=title, heading="Remotes", cols=["Name", "URL", "Push URL"], rows=rows)


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 ""
    lines = text.split("\n")
    num_lines = max(1, len(lines))
    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
    return render_page(
        "blob.html",
        title=f"{f} - {project}",
        code=sanitize(text) or "",
        line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
        lang=BLOB_LANG.get(ext, ""),
        blame=False,
    )


def git_blog_raw(project: str, path: str) -> Response:
    """Serve a blob from HEAD for the project blog viewer."""
    if not is_valid_pathname(path):
        raise HTTPException(status_code=404, detail="File not found")
    result = get_blob_at_ref_path(project, "HEAD", path)
    if not result:
        raise HTTPException(status_code=404, detail="File not found")
    blob, _ = result
    data = blob.data
    media_type, _ = mimetypes.guess_type(path.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)


def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
    return hunk.final_start_line_number + hunk.lines_in_hunk - 1


def _merge_blame_hunks(repo: pygit2.Repository, blame: pygit2.Blame) -> list[BlameHunkInfo]:
    out: list[BlameHunkInfo] = []
    i = 0
    n = len(blame)
    while i < n:
        hunk = blame[i]
        cid = str(hunk.final_commit_id)
        line_start = hunk.final_start_line_number
        line_end = _blame_hunk_end_line(hunk)
        j = i + 1
        while j < n:
            nh = blame[j]
            if str(nh.final_commit_id) != cid:
                break
            if nh.final_start_line_number != line_end + 1:
                break
            line_end = _blame_hunk_end_line(nh)
            j += 1
        try:
            bc = repo[cid].peel(pygit2.Commit)
        except (KeyError, pygit2.GitError, ValueError) as e:
            raise HTTPException(status_code=500, detail=f"Missing blame commit {cid}") from e
        auth = bc.author
        tzinfo = timezone(timedelta(minutes=auth.offset))
        dt = datetime.fromtimestamp(float(auth.time), tz=tzinfo)
        out.append({
            "commit_id": cid,
            "commit_msg": bc.message.rstrip("\n"),
            "commit_author": f"{auth.name} <{auth.email}>",
            "time": dt.isoformat(),
            "line_start": line_start,
            "line_end": line_end,
            "author_name": auth.name,
            "time_display": _format_date(auth.time, auth.offset),
        })
        i = j
    return out


def _blame_for_file(project: str, h: str | None, f: str) -> list[BlameHunkInfo]:
    if not is_valid_pathname(f):
        raise HTTPException(status_code=400, detail="Invalid path")
    if not get_blob_at_ref_path(project, h, f):
        raise HTTPException(status_code=404, detail="File not found")
    repo = open_repo(project)
    base_ref = h or (str(repo.head.target) if repo.head else None)
    if not base_ref:
        raise HTTPException(status_code=404, detail="No revision to blame")
    try:
        commit_tip = repo.revparse_single(base_ref).peel(pygit2.Commit)
    except (KeyError, pygit2.GitError, ValueError) as e:
        raise HTTPException(status_code=400, detail="Invalid revision for blame") from e
    path_clean = f.strip("/")
    try:
        blame = repo.blame(path_clean, newest_commit=commit_tip.id)
    except (pygit2.GitError, OSError) as e:
        raise HTTPException(status_code=400, detail=f"Blame failed: {e}") from e
    return _merge_blame_hunks(repo, blame)


def _blame_line_links(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> list[dict[str, str] | None]:
    hunk_by_start = {h["line_start"]: h for h in hunks}
    lines: list[dict[str, str] | None] = []
    for line_no in range(1, num_lines + 1):
        hunk = hunk_by_start.get(line_no)
        if hunk is None:
            lines.append(None)
            continue
        cid = hunk["commit_id"]
        lines.append({
            "href": f"/project/{project}?a=commit&h={quote(cid, safe='')}",
            "title": f"{hunk['author_name']} — {hunk['time_display']}",
            "oid_short": cid[:7],
        })
    return lines


def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
    """Blame page: file content with per-range commit links and line numbers."""
    if not f:
        raise HTTPException(status_code=400, detail="File path (f) required")
    result = get_blob_at_ref_path(project, h, f)
    if not result:
        raise HTTPException(status_code=404, detail="File not found")
    blob, _ref_oid = result
    hunks = _blame_for_file(project, h, f)
    text = to_utf8(blob.data) or ""
    lines = text.split("\n")
    num_lines = max(1, len(lines))
    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
    return render_page(
        "blob.html",
        title=f"Blame {f} - {project}",
        code=sanitize(text) or "",
        line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
        lang=BLOB_LANG.get(ext, ""),
        blame=True,
        blame_lines=_blame_line_links(project, hunks, num_lines),
    )


def git_blame_raw(project: str, h: str | None, f: str | None) -> JSONResponse:
    """Per-file blame as JSON: merged contiguous hunks per commit with line ranges."""
    if not f:
        raise HTTPException(status_code=400, detail="File path (f) required")
    hunks = _blame_for_file(project, h, f)
    out = [
        {
            "commit_id": h["commit_id"],
            "commit_msg": h["commit_msg"],
            "commit_author": h["commit_author"],
            "time": h["time"],
            "line_range": (
                str(h["line_start"]) if h["line_start"] == h["line_end"] else f"{h['line_start']}-{h['line_end']}"
            ),
        }
        for h in hunks
    ]
    return JSONResponse(out)


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
    crumbs: list[dict[str, str]] = []
    if f:
        parts = f.strip("/").split("/")
        for i, seg in enumerate(parts):
            prefix = "/".join(parts[: i + 1])
            crumbs.append({"href": _tree_url(project, ref_oid, prefix), "label": seg})
    title_path = f" / {f}" if f else ""
    readme_html = ""
    needs_showdown = False
    readme = get_readme_at_ref_path(project, ref_oid, f or "")
    if readme:
        readme_filename, readme_content = readme
        needs_showdown = readme_filename.lower().endswith(".md")
        readme_html = _render_readme_card(project, ref_oid, readme_filename, readme_content, f or "")
    return render_page(
        "tree.html",
        title=f"{project}{title_path} - Tree",
        project=project,
        project_href=f"/project/{project}",
        breadcrumbs=crumbs,
        path=f or "",
        tree_html=trusted_html(_render_tree_table(project, h, f)),
        readme_html=trusted_html(readme_html),
        needs_showdown=needs_showdown,
    )


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 - {project} @ {ref_display}"
    return _table_page(
        title=title,
        cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
        rows=rows,
        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
    )


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 - {project} @ {ref_display}"
    return _table_page(
        title=title,
        cols=["Commit", "Subject", "Author", "Age", "Diff"],
        rows=rows,
        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
    )


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 - {f} - {project} @ {ref_display}"
    return _table_page(
        title=title,
        project=project,
        cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
        rows=rows,
        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
    )


def git_heads(project: str) -> HTMLResponse:
    """Heads (branches) list page. Port of git_heads."""
    visible_heads = git_get_heads_list(project)
    if not visible_heads:
        raise HTTPException(status_code=404, detail="No heads found")
    rows: list[list[Cell]] = []
    for name, _ref, oid in visible_heads:
        oid_short = oid[:7] if oid else ""
        commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
        tree_href = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
        rows.append([
            html_link(commit_href, name),
            html_link(commit_href, oid_short),
            html_link(tree_href, "tree"),
        ])
    title = f"Heads - {project}"
    return _table_page(
        title=title,
        project=project,
        cols=["Head", "Commit", ""],
        rows=rows,
    )


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 as e:
        raise HTTPException(status_code=404, detail="Repository not found") from e
    rows = []
    for name, _ref, oid in tags_page:
        tag_link = f"/project/{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/{project}?a=commit&h={quote(target_oid, safe='')}"
        elif target_type == "tree":
            target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
        else:
            target_link = None
        obj_cell: Cell = html_link(target_link, target_short) if target_link else target_short
        rows.append([
            html_link(tag_link, name),
            obj_cell,
        ])
    title = f"Tags - {project}"
    return _table_page(
        title=title,
        project=project,
        cols=["Tag", "Object"],
        rows=rows,
        pagination=_pagination_context(request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages),
    )


def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLResponse | RedirectResponse:
    """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) as e:
        raise HTTPException(status_code=404, detail="Tag or object not found") from e
    if not isinstance(obj, pygit2.Tag):
        raise HTTPException(status_code=404, detail="Not a tag object")
    mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
    if mr_pair is not None:
        obj, mr = mr_pair
    else:
        mr = None
    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]
    if mr is not None:
        return merge_request_tag_response(
            project=project,
            repo=repo,
            mr=mr,
            tag_name=tag_name,
            tag_oid=tag_oid,
            tagger=tagger,
            tagger_date=_format_date(tagger_epoch, tagger_tz),
            notice=notice,
        )
    target_short = target_oid[:7] if target_oid else ""
    if target_type == "commit":
        object_value: Cell = html_link(
            f"/project/{project}?a=commit&h={quote(target_oid, safe='')}",
            target_short,
        )
    elif target_type == "tree":
        object_value = html_link(
            f"/project/{project}?a=tree&h={quote(target_oid, safe='')}",
            target_short,
        )
    else:
        object_value = target_short
    table_rows: list[list[Cell]] = [
        ["Tag", tag_name or ""],
        ["Object", html_join([object_value, " (", target_type, ")"])],
        ["Tagger", tagger],
        ["Date", _format_date(tagger_epoch, tagger_tz)],
    ]
    if message:
        table_rows.append(["Message", html_format("<pre class='tag-message'>{}</pre>", message)])
    return _table_page(
        title=f"Tag {tag_name} - {project}",
        heading=f"Tag {tag_name}",
        project=project,
        cols=["Field", "Value"],
        rows=table_rows,
    )


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) as e:
        raise HTTPException(status_code=404, detail="Commit not found") from e
    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/{project}?a=commit&h={quote(oid, safe='')}"
    tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
    diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
    patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
    table_rows: list[list[Cell]] = [
        ["commit", html_link(commit_link, oid)],
        ["Author", html_join([author, " <", author_email, ">"])],
        ["Date", author_date],
        ["tree", html_link(tree_link, tree_oid[:7])],
    ]
    for i, parent_id in enumerate(commit.parent_ids):
        parent_oid = str(parent_id)
        parent_short = parent_oid[:7]
        parent_href = f"/project/{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, html_link(parent_href, parent_short)])
    if message:
        table_rows.append(["", html_format("<pre class='commit-message'>{}</pre>", message)])
    table_rows.append(["", html_join([html_link(diff_link, "diff"), " · ", html_link(patch_link, "patch")])])
    return _table_page(
        title=f"Commit {oid_short} - {project}",
        heading=f"Commit {oid_short}",
        project=project,
        cols=["Field", "Value"],
        rows=table_rows,
    )


def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
    """Commit diff page: unified diff rendered with diff2html."""
    diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
    return render_page(
        "commitdiff.html",
        title=f"Commit diff {oid_short} - {project}",
        project=project,
        oid_short=oid_short,
        diff_b64=diff_b64,
        commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
    )


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")
    return render_page(
        "blobdiff.html",
        title=f"Blob diff - {path_new} - {project}",
        project=project,
        path_new=path_new,
        diff_b64=diff_b64,
        blob_link=f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}",
    )


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}"'},
    )