diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a230a78
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.venv/
+__pycache__/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..93c3eb3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# DistGit: Federated Version Control
+
+DistGit allows users to:
+- Self-host version control server instances
+- Securely broadcast signed commits and other repo information
+- Mirror public repositories on your network
+- Setup hooks and jobs to run on your machine
diff --git a/distgit/README.md b/distgit/README.md
new file mode 100644
index 0000000..b5a5002
--- /dev/null
+++ b/distgit/README.md
@@ -0,0 +1,5 @@
+# DistGit (Core)
+
+This library is all you need to run a distgit server to authenticate and communicate with the universe of servers.
+
+Running distgit alone will not provide a web viewer for your projects.
diff --git a/postgit/README.md b/postgit/README.md
new file mode 100644
index 0000000..a6f64f7
--- /dev/null
+++ b/postgit/README.md
@@ -0,0 +1,6 @@
+# PostGit
+
+Tool for transferring data between Git (Git's Object DB) and Postgres.
+- Use git history to populate tables and audit tables
+- Use audit tables to populate git history
+- Repo settings, notes, and other misc. info can be stored (and read by distgit)
diff --git a/pygitweb/README.md b/pygitweb/README.md
new file mode 100644
index 0000000..6eb8ed0
--- /dev/null
+++ b/pygitweb/README.md
@@ -0,0 +1,59 @@
+# Pygitweb
+
+Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported from `git/gitweb/gitweb.perl`.
+
+## Setup
+
+```bash
+cd pygitweb
+python3 -m venv .venv
+source .venv/bin/activate   # or .venv\Scripts\activate on Windows
+pip install -r requirements.txt
+```
+
+## Run
+
+```bash
+cd pygitweb
+.venv/bin/uvicorn pygitweb.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+Or:
+
+```bash
+cd pygitweb && .venv/bin/python -m pygitweb.main
+```
+
+## Config
+
+Set environment variables (or use a Python config file via `GITWEB_CONFIG`):
+
+- `GITWEB_PROJECTROOT` — absolute path to directory containing git repositories (default: `/pub/scm`)
+- `GITWEB_LIST` — same path or path to a project-list file
+- `GITWEB_EXPORT_OK` — filename that must exist to allow export (e.g. `git-daemon-export-ok`); empty = no check
+- `GITWEB_SITENAME` — site name in titles
+
+## Routes
+
+See [ROUTES.md](ROUTES.md) for more detail.
+
+**No-project routes**
+
+- `GET /` — project list
+- `GET /index` — plain text project index (path, owner)
+- `GET /opml` — OPML feed list
+
+**Project-scoped actions (implemented)**
+
+- `GET /{project}` or `a=summary` — project summary (description, owner, HEAD, tree link)
+- `GET /{project}?a=forks` — forks of project
+- `GET /{project}?a=tree&h=...&f=...` — directory listing (tree)
+- `GET /{project}?a=blob&h=...&f=...` — file view (HTML)
+- `GET /{project}?a=blob_plain&h=...&f=...` — raw file download
+- `GET /{project}?a=log&h=...` — commit log
+- `GET /{project}?a=shortlog&h=...` — shortlog
+- `GET /{project}?a=history&h=...&f=...` — history of a file or path
+
+**Project-scoped actions (stub only)**
+
+These actions are accepted but return a minimal placeholder page. Add handlers in `main.py` to implement: `blame`, `blame_incremental`, `blame_data`, `blobdiff`, `blobdiff_plain`, `commit`, `commitdiff`, `commitdiff_plain`, `heads`, `patch`, `patches`, `remotes`, `rss`, `atom`, `search`, `search_help`, `tag`, `tags`, `snapshot`, `object`.
diff --git a/pygitweb/ROUTES.md b/pygitweb/ROUTES.md
new file mode 100644
index 0000000..1cd0cd7
--- /dev/null
+++ b/pygitweb/ROUTES.md
@@ -0,0 +1,66 @@
+# Pygitweb Routes (Gitweb actions)
+
+Routes are implemented as FastAPI path operations. Path info is parsed from path params and query.
+
+## No project required
+
+| Action         | Method | Path / Query                          | Handler          |
+|----------------|--------|---------------------------------------|------------------|
+| project_list   | GET    | `/`                                   | git_project_list |
+| project_index  | GET    | `/?a=project_index`                   | git_project_index|
+| opml           | GET    | `/?a=opml`                            | git_opml         |
+
+## Project required
+
+| Action         | Method | Path / Query                          | Handler          |
+|----------------|--------|---------------------------------------|------------------|
+| summary        | GET    | `/{project}` or `/{project}/?a=summary` | git_summary    |
+| log            | GET    | `/{project}/log/{hash}`               | git_log          |
+| shortlog       | GET    | `/{project}/shortlog/{hash}`          | git_shortlog     |
+| history        | GET    | `/{project}/history/{hash}` or `/{hash}/path` | git_history |
+| commit         | GET    | `/{project}/commit/{hash}`            | git_commit       |
+| commitdiff     | GET    | `/{project}/commitdiff/{hash}`        | git_commitdiff   |
+| commitdiff_plain | GET  | `/{project}/commitdiff/{hash}` (Accept: patch) | git_commitdiff_plain |
+| patch          | GET    | `/{project}/patch/{hash}`             | git_patch        |
+| patches        | GET    | `/{project}/patches/{hash}`           | git_patches      |
+| tree           | GET    | `/{project}/tree/{hash}` or `/{hash}/path/` | git_tree     |
+| blob           | GET    | `/{project}/blob/{hash}/path`         | git_blob         |
+| blob_plain     | GET    | `/{project}/blob/{hash}/path` (raw)    | git_blob_plain   |
+| blobdiff       | GET    | `/{project}/blobdiff/{hash_base}..{hash}/path` | git_blobdiff |
+| blobdiff_plain | GET    | same, raw                             | git_blobdiff_plain |
+| blame          | GET    | `/{project}/blame/{hash}/path`        | git_blame        |
+| blame_incremental | GET  | (incremental)                         | git_blame_incremental |
+| blame_data     | GET    | (JSON)                                | git_blame_data   |
+| tags           | GET    | `/{project}/tags`                     | git_tags         |
+| tag            | GET    | `/{project}/tag/{hash}`               | git_tag          |
+| heads          | GET    | `/{project}/heads`                    | git_heads        |
+| remotes        | GET    | `/{project}/remotes`                  | git_remotes      |
+| search         | GET    | `/{project}/search`                   | git_search       |
+| search_help    | GET    | `/{project}/search_help`              | git_search_help  |
+| snapshot       | GET    | `/{project}/snapshot/{hash}.{ext}`    | git_snapshot     |
+| object         | GET    | (dispatch by object type when only hash) | git_object   |
+| forks          | GET    | `/{project}/forks`                    | git_forks        |
+| rss            | GET    | `/{project}/rss`                      | git_rss          |
+| atom           | GET    | `/{project}/atom`                     | git_atom         |
+| feed           | GET    | (rss/atom dispatcher)                 | git_feed         |
+
+## Query parameter short names (CGI mapping)
+
+- `p` → project
+- `a` → action
+- `f` → file_name
+- `fp` → file_parent
+- `h` → hash
+- `hp` → hash_parent
+- `hb` → hash_base
+- `hpb` → hash_parent_base
+- `pg` → page
+- `o` → order
+- `s` → searchtext
+- `st` → searchtype
+- `sf` → snapshot_format
+- `opt` → extra_options
+- `sr` → search_use_regexp
+- `by_tag` → ctag
+- `ds` → diff_style
+- `pf` → project_filter
diff --git a/pygitweb/__init__.py b/pygitweb/__init__.py
new file mode 100644
index 0000000..83e184f
--- /dev/null
+++ b/pygitweb/__init__.py
@@ -0,0 +1,6 @@
+from pygitweb.main import app
+
+"""
+Git Repo Browser built with FastAPI + Pygit2
+"""
+__version__ = "1.0.0"
diff --git a/pygitweb/config.py b/pygitweb/config.py
new file mode 100644
index 0000000..3a53189
--- /dev/null
+++ b/pygitweb/config.py
@@ -0,0 +1,233 @@
+"""
+Gitweb configuration: settings, config file loading, loadavg, features, snapshot formats.
+Ported from gitweb/gitweb.perl (evaluate_gitweb_config, read_config_file, get_loadavg,
+check_loadavg, known_snapshot_formats, feature_*, gitweb_get_feature, gitweb_check_feature,
+filter_snapshot_fmts, filter_and_validate_refs, configure_gitweb_features, get_branch_refs).
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+# Defaults (equivalent to @GITWEB_*@ in gitweb.perl)
+PROJECTROOT = os.environ.get("GITWEB_PROJECTROOT", "/pub/scm")
+PROJECT_MAXDEPTH = int(os.environ.get("GITWEB_PROJECT_MAXDEPTH", "2"))
+PROJECTS_LIST = os.environ.get("GITWEB_LIST", PROJECTROOT)
+SITE_NAME = os.environ.get("GITWEB_SITENAME", "") or "Git"
+EXPORT_OK = os.environ.get("GITWEB_EXPORT_OK", "")
+# When True, list all directories under project root without repo/export_ok checks (default on for now).
+LIST_ALL = os.environ.get("GITWEB_LIST_ALL", "1").lower() in ("1", "true", "yes")
+STRICT_EXPORT = os.environ.get("GITWEB_STRICT_EXPORT", "0").lower() in ("1", "true", "yes")
+GIT_BINDIR = os.environ.get("GIT_BINDIR", "")
+GIT = (GIT_BINDIR + "/git") if GIT_BINDIR else "git"
+MAXLOAD: float | None = None  # 300 in perl; None = disabled
+
+# Config file paths (can be overridden by env)
+GITWEB_CONFIG = os.environ.get("GITWEB_CONFIG", "")
+GITWEB_CONFIG_SYSTEM = os.environ.get("GITWEB_CONFIG_SYSTEM", "")
+GITWEB_CONFIG_COMMON = os.environ.get("GITWEB_CONFIG_COMMON", "")
+
+# Snapshot formats (from %known_snapshot_formats)
+KNOWN_SNAPSHOT_FORMATS: dict[str, dict[str, Any]] = {
+    "tgz": {
+        "display": "tar.gz",
+        "type": "application/x-gzip",
+        "suffix": ".tar.gz",
+        "format": "tar",
+        "compressor": ["gzip", "-n"],
+    },
+    "tbz2": {
+        "display": "tar.bz2",
+        "type": "application/x-bzip2",
+        "suffix": ".tar.bz2",
+        "format": "tar",
+        "compressor": ["bzip2"],
+    },
+    "txz": {
+        "display": "tar.xz",
+        "type": "application/x-xz",
+        "suffix": ".tar.xz",
+        "format": "tar",
+        "compressor": ["xz"],
+        "disabled": True,
+    },
+    "zip": {
+        "display": "zip",
+        "type": "application/zip",
+        "suffix": ".zip",
+        "format": "zip",
+    },
+}
+
+KNOWN_SNAPSHOT_FORMAT_ALIASES: dict[str, str | None] = {
+    "gzip": "tgz",
+    "bzip2": "tbz2",
+    "xz": "txz",
+    "x-gzip": None,
+    "gz": None,
+    "x-bzip2": None,
+    "bz2": None,
+    "x-zip": None,
+    "": None,
+}
+
+# Feature defaults; override in config or per-repo
+_feature_snapshot_default = ["tgz"]
+_snapshot_fmts: list[str] = []
+_extra_branch_refs: list[str] = []
+
+
+def read_config_file(filename: str | None) -> bool:
+    """Load and execute a Python config file. Returns True on success. Port of read_config_file."""
+    if not filename or not os.path.exists(filename):
+        return False
+    try:
+        with open(filename) as f:
+            code = compile(f.read(), filename, "exec")
+            glob = {
+                "PROJECTROOT": PROJECTROOT,
+                "PROJECTS_LIST": PROJECTS_LIST,
+                "SITE_NAME": SITE_NAME,
+                "EXPORT_OK": EXPORT_OK,
+                "LIST_ALL": LIST_ALL,
+                "STRICT_EXPORT": STRICT_EXPORT,
+                "GIT": GIT,
+                "MAXLOAD": MAXLOAD,
+                "KNOWN_SNAPSHOT_FORMATS": KNOWN_SNAPSHOT_FORMATS,
+                "os": os,
+                "Path": Path,
+            }
+            exec(code, glob)
+            for k in ("PROJECTROOT", "PROJECTS_LIST", "SITE_NAME", "EXPORT_OK", "LIST_ALL", "STRICT_EXPORT", "GIT", "MAXLOAD", "KNOWN_SNAPSHOT_FORMATS"):
+                if k in glob:
+                    globals()[k] = glob[k]
+        return True
+    except Exception:
+        raise
+
+
+def evaluate_gitweb_config() -> None:
+    """Resolve config paths and load common + instance/system config. Port of evaluate_gitweb_config."""
+    global GITWEB_CONFIG, GITWEB_CONFIG_SYSTEM, GITWEB_CONFIG_COMMON
+    if not GITWEB_CONFIG:
+        GITWEB_CONFIG = os.environ.get("GITWEB_CONFIG", "")
+    if not GITWEB_CONFIG_SYSTEM:
+        GITWEB_CONFIG_SYSTEM = os.environ.get("GITWEB_CONFIG_SYSTEM", "")
+    if not GITWEB_CONFIG_COMMON:
+        GITWEB_CONFIG_COMMON = os.environ.get("GITWEB_CONFIG_COMMON", "")
+
+    if GITWEB_CONFIG == GITWEB_CONFIG_COMMON:
+        GITWEB_CONFIG = ""
+    if GITWEB_CONFIG_SYSTEM == GITWEB_CONFIG_COMMON:
+        GITWEB_CONFIG_SYSTEM = ""
+
+    if GITWEB_CONFIG_COMMON and os.path.exists(GITWEB_CONFIG_COMMON):
+        read_config_file(GITWEB_CONFIG_COMMON)
+    if GITWEB_CONFIG and os.path.exists(GITWEB_CONFIG):
+        read_config_file(GITWEB_CONFIG)
+        return
+    if GITWEB_CONFIG_SYSTEM and os.path.exists(GITWEB_CONFIG_SYSTEM):
+        read_config_file(GITWEB_CONFIG_SYSTEM)
+
+
+def get_loadavg() -> float:
+    """First element of load average, or 0 if unavailable. Port of get_loadavg."""
+    try:
+        return os.getloadavg()[0]
+    except (OSError, AttributeError):
+        pass
+    try:
+        with open("/proc/loadavg") as f:
+            return float(f.read().split()[0])
+    except (OSError, ValueError):
+        return 0.0
+
+
+def check_loadavg() -> None:
+    """Raise 503 if load exceeds maxload. Port of check_loadavg."""
+    if MAXLOAD is not None and get_loadavg() > MAXLOAD:
+        raise RuntimeError("503:The load average on the server is too high")
+
+
+def gitweb_get_feature(
+    name: str,
+    git_dir: str | None = None,
+    get_project_config: Any = None,
+) -> list[Any]:
+    """Return feature value(s); project override when git_dir and get_project_config set. Port of gitweb_get_feature."""
+    if name == "snapshot":
+        defaults = _feature_snapshot_default
+        if git_dir and get_project_config:
+            val = get_project_config("snapshot") if callable(get_project_config) else None
+            if val:
+                defaults = [] if val.strip().lower() == "none" else [x.strip() for x in re.split(r"[\s,]+", val) if x.strip()]
+        return list(defaults)
+    if name == "avatar":
+        return ["gravatar"]  # default
+    if name == "extra-branch-refs":
+        if git_dir and get_project_config and callable(get_project_config):
+            val = get_project_config("extrabranchrefs")
+            if val:
+                parts = [val] if isinstance(val, str) else (val if isinstance(val, list) else [])
+                return [x for part in parts for x in str(part).split()]
+        return []
+    return []
+
+
+def gitweb_check_feature(name: str, git_dir: str | None = None, get_project_config: Any = None) -> bool | Any:
+    """First value of gitweb_get_feature. Port of gitweb_check_feature."""
+    vals = gitweb_get_feature(name, git_dir, get_project_config)
+    return vals[0] if vals else False
+
+
+def filter_snapshot_fmts(fmts: list[str]) -> list[str]:
+    """Resolve aliases and drop unknown/disabled. Port of filter_snapshot_fmts."""
+    result = []
+    for f in fmts:
+        key = KNOWN_SNAPSHOT_FORMAT_ALIASES.get(f, f)
+        if key is None:
+            continue
+        if key not in KNOWN_SNAPSHOT_FORMATS:
+            continue
+        opt = KNOWN_SNAPSHOT_FORMATS[key]
+        if opt.get("disabled"):
+            continue
+        result.append(key)
+    return result
+
+
+def filter_and_validate_refs(refs: list[str], is_valid_ref_format: Any) -> list[str]:
+    """Validate ref names and unique sort; 'heads' omitted (added in get_branch_refs). Port of filter_and_validate_refs."""
+    seen: set[str] = set()
+    for ref in refs:
+        if not is_valid_ref_format(ref):
+            raise ValueError(f"Invalid ref '{ref}' in 'extra-branch-refs' feature")
+        if ref != "heads":
+            seen.add(ref)
+    return sorted(seen)
+
+
+def configure_gitweb_features(
+    get_project_config: Any = None,
+    git_dir: str | None = None,
+    is_valid_ref_format: Any = None,
+) -> None:
+    """Set snapshot_fmts and extra_branch_refs. Port of configure_gitweb_features."""
+    global _snapshot_fmts, _extra_branch_refs
+    _snapshot_fmts = filter_snapshot_fmts(gitweb_get_feature("snapshot", git_dir, get_project_config))
+    avatar = gitweb_get_feature("avatar", git_dir, get_project_config)
+    if avatar and avatar[0] not in ("gravatar", "picon"):
+        avatar = [""]
+    raw = gitweb_get_feature("extra-branch-refs", git_dir, get_project_config)
+    _extra_branch_refs = filter_and_validate_refs(raw, is_valid_ref_format) if is_valid_ref_format else []
+
+
+def get_branch_refs() -> list[str]:
+    """Return ['heads', ...extra_branch_refs]. Port of get_branch_refs."""
+    return ["heads"] + _extra_branch_refs
+
+
+def get_snapshot_fmts() -> list[str]:
+    return _snapshot_fmts
diff --git a/pygitweb/formatting.py b/pygitweb/formatting.py
new file mode 100644
index 0000000..5334350
--- /dev/null
+++ b/pygitweb/formatting.py
@@ -0,0 +1,251 @@
+"""
+Formatting: escaping (esc_param, esc_path_info, esc_url, esc_attr, esc_html, esc_path, sanitize),
+quot_cec, quot_upr, unquote, untabify, to_utf8, chop_str, chop_and_escape_str, age_class, age_string.
+Ported from gitweb/gitweb.perl.
+"""
+from __future__ import annotations
+
+import html
+import re
+from urllib.parse import quote, quote_plus, unquote as url_unquote
+
+# Fallback encoding when bytes are not valid UTF-8 (gitweb: $fallback_encoding)
+FALLBACK_ENCODING = "latin1"
+
+# Control character escape codes (CEC). Port of quot_cec.
+_CEC_MAP = {
+    "\t": r"\t",
+    "\n": r"\n",
+    "\r": r"\r",
+    "\f": r"\f",
+    "\b": r"\b",
+    "\a": r"\a",
+    "\x1b": r"\e",
+    "\v": r"\v",
+    "\0": r"\0",
+}
+
+
+def to_utf8(s: str | bytes | None) -> str | None:
+    """Decode to UTF-8 string; use fallback encoding if not valid UTF-8. Port of to_utf8."""
+    if s is None:
+        return None
+    if isinstance(s, str):
+        return s
+    try:
+        return s.decode("utf-8")
+    except UnicodeDecodeError:
+        return s.decode(FALLBACK_ENCODING, errors="replace")
+
+
+def esc_param(s: str | None) -> str | None:
+    """URL-encode for query param; keep / and space as +. Port of esc_param."""
+    if s is None:
+        return None
+    return quote_plus(s, safe="")  # gitweb keeps -_.~()/@: and space→+
+
+
+def esc_path_info(s: str | None) -> str | None:
+    """Path segment encoding; ? must be escaped. Port of esc_path_info."""
+    if s is None:
+        return None
+    # Safe: A-Za-z0-9\-_.~();/;:@&= +
+    return quote(s, safe="-_.~();/:@&= ")
+
+
+def esc_url(s: str | None) -> str | None:
+    """URL encoding for href. Port of esc_url (same idea as esc_param)."""
+    if s is None:
+        return None
+    return quote(s, safe="-_.~()/:@!")
+
+
+def esc_attr(s: str | None) -> str | None:
+    """Escape for HTML attribute. Port of esc_attr."""
+    if s is None:
+        return None
+    return html.escape(s, quote=True)
+
+
+def esc_html(s: str | None) -> str | None:
+    """Escape for HTML body. Port of esc_html."""
+    if s is None:
+        return None
+    return html.escape(s, quote=False)
+
+
+def quot_cec(char: str, nohtml: bool = False) -> str:
+    """Printable representation of control char (CEC). Port of quot_cec."""
+    out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
+    if nohtml:
+        return out
+    return f'<span class="cntrl">{out}</span>'
+
+
+def quot_upr(char: str, nohtml: bool = False) -> str:
+    """Unicode control pictures. Port of quot_upr."""
+    code = 0x2400 + ord(char)
+    out = f"&#{code};"
+    if nohtml:
+        return out
+    return f'<span class="cntrl">{out}</span>'
+
+
+def esc_path(s: str | None, nbsp: bool = False) -> str | None:
+    """UTF-8, HTML-escape, then control chars to quot_cec. Port of esc_path."""
+    if s is None:
+        return None
+    s = to_utf8(s) or s
+    s = html.escape(s, quote=False)
+    if nbsp:
+        s = s.replace(" ", "&nbsp;")
+    result = []
+    for c in s:
+        if ord(c) < 32 or ord(c) == 127:
+            result.append(quot_cec(c))
+        else:
+            result.append(c)
+    return "".join(result)
+
+
+def sanitize(s: str | None) -> str | None:
+    """XHTML-safe: control chars to CEC except tab/lf/cr. Port of sanitize."""
+    if s is None:
+        return None
+    s = to_utf8(s) or s
+    result = []
+    for c in s:
+        if c in "\t\n\r":
+            result.append(c)
+        elif ord(c) < 32 or ord(c) == 127:
+            result.append(quot_cec(c, nohtml=True))
+        else:
+            result.append(c)
+    return "".join(result)
+
+
+def unquote(s: str | None) -> str:
+    """Unescape git-style quoted filenames (C and octal). Port of unquote."""
+    if s is None:
+        return ""
+
+    def unq(seq: str) -> str:
+        es = {"t": "\t", "n": "\n", "r": "\r", "f": "\f", "b": "\b", "a": "\a", "e": "\x1b", "v": "\v"}
+        if re.match(r"^[0-7]{1,3}$", seq):
+            return chr(int(seq, 8))
+        return es.get(seq, seq)
+
+    if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
+        s = s[1:-1]
+        s = re.sub(r"\\([^0-7]|[0-7]{1,3})", lambda m: unq(m.group(1)), s)
+    return s
+
+
+def untabify(line: str, tabwidth: int = 8) -> str:
+    """Expand tabs to spaces. Port of untabify."""
+    result = []
+    col = 0
+    for c in line:
+        if c == "\t":
+            n = tabwidth - (col % tabwidth)
+            result.append(" " * n)
+            col += n
+        else:
+            result.append(c)
+            col += 1
+    return "".join(result)
+
+
+def chop_str(
+    s: str,
+    length: int,
+    add_len: int = 10,
+    where: str = "right",
+) -> str:
+    """Chop on word boundary between length and length+add_len. Port of chop_str."""
+    s = to_utf8(s) or ""
+    if where == "center":
+        if length + 5 >= len(s):
+            return s
+        half = length // 2
+        endre = re.compile(rf".{{{half}}}\w{{0,{add_len}}}", re.DOTALL)
+        begre = re.compile(rf"\w{{0,{add_len}}}.{{{half}}}$", re.DOTALL)
+        m1 = re.match(rf"^(.{{{half}}}\w{{0,{add_len}}})(.*)$", s, re.DOTALL)
+        if not m1:
+            return s[: length // 2] + " ... " + s[-(length // 2) :]
+        left, rest = m1.group(1), m1.group(2)
+        m2 = re.match(rf"^(.*?)(\w{{0,{add_len}}}.{{{half}}})$", rest, re.DOTALL)
+        if not m2:
+            return left + " ... " + rest[-half:]
+        mid, right = m2.group(1), m2.group(2)
+        if len(mid) > 5:
+            mid = " ... "
+        return left + mid + right
+    if length + 4 >= len(s):
+        return s
+    if where == "left":
+        begre = re.compile(rf"\w{{0,{add_len}}}.{{{length}}}$")
+        m = begre.search(s)
+        if m:
+            body = m.group(0)
+            lead = s[: m.start()]
+            if len(lead) > 4:
+                lead = " ..."
+            return lead + body
+        return s
+    # right
+    endre = re.compile(rf".{{{length}}}\w{{0,{add_len}}}")
+    m = endre.match(s)
+    if m:
+        body = m.group(0)
+        tail = s[m.end() :]
+        if len(tail) > 4:
+            tail = "... "
+        return body + tail
+    return s
+
+
+def chop_and_escape_str(
+    s: str,
+    length: int,
+    add_len: int = 10,
+    where: str = "right",
+) -> str:
+    """Chop then HTML-escape; wrap in span with title if chopped. Port of chop_and_escape_str."""
+    chopped = chop_str(s, length, add_len, where)
+    s = to_utf8(s) or s
+    if chopped == s:
+        return esc_html(chopped) or ""
+    title = esc_attr(s.replace("\n", " ").replace("\r", "?"))
+    escaped = esc_html(chopped) or ""
+    return f'<span title="{title}">{escaped}</span>'
+
+
+def age_class(age_seconds: float | None) -> str:
+    """CSS class for age. Port of age_class."""
+    if age_seconds is None:
+        return "noage"
+    if age_seconds < 2 * 3600:
+        return "age0"
+    if age_seconds < 2 * 86400:
+        return "age1"
+    return "age2"
+
+
+def age_string(age_seconds: float) -> str:
+    """Human-readable age. Port of age_string."""
+    if age_seconds > 2 * 365 * 86400:
+        return f"{int(age_seconds / 86400 / 365)} years ago"
+    if age_seconds > 2 * (365 / 12) * 86400:
+        return f"{int(age_seconds / 86400 / (365/12))} months ago"
+    if age_seconds > 2 * 7 * 86400:
+        return f"{int(age_seconds / 86400 / 7)} weeks ago"
+    if age_seconds > 2 * 86400:
+        return f"{int(age_seconds / 86400)} days ago"
+    if age_seconds > 2 * 3600:
+        return f"{int(age_seconds / 3600)} hours ago"
+    if age_seconds > 2 * 60:
+        return f"{int(age_seconds / 60)} min ago"
+    if age_seconds > 2:
+        return f"{int(age_seconds)} sec ago"
+    return "right now"
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
new file mode 100644
index 0000000..53c3819
--- /dev/null
+++ b/pygitweb/git_helpers.py
@@ -0,0 +1,465 @@
+"""
+Git helpers via pygit2: open repo, rev_parse, config, refs, ls_tree, cat_file, etc.
+Ported from gitweb/gitweb.perl (git_cmd, git_get_head_hash, git_get_hash, git_get_type,
+git_parse_project_config, config_to_bool, config_to_int, config_to_multi, git_get_project_config,
+git_get_hash_by_path, git_get_path_by_hash, git_get_file_or_project_config,
+git_get_project_description, git_get_project_category, git_get_references, git_get_heads_list,
+git_get_tags_list, git_get_remotes_list, parse_commit, parse_tag, etc.).
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+import pygit2
+
+# From config
+from pygitweb.config import PROJECTROOT, GIT
+
+
+def _repo_path(project: str) -> str:
+    return os.path.join(PROJECTROOT, project)
+
+
+def open_repo(project: str):
+    """Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
+    path = _repo_path(project)
+    return pygit2.Repository(path)
+
+
+def git_get_head_hash(project: str) -> str | None:
+    """HEAD commit OID. Port of git_get_head_hash (pygit2: repo.head.target)."""
+    try:
+        repo = open_repo(project)
+        return str(repo.head.target) if repo.head else None
+    except (pygit2.GitError, OSError):
+        return None
+
+
+def git_get_full_hash(project: str, ref: str) -> str | None:
+    """Full OID for ref. Port of git_get_full_hash (pygit2 rev_parse)."""
+    return git_get_hash(project, ref)
+
+
+def git_get_short_hash(project: str, ref: str, length: int = 7) -> str | None:
+    """Short OID. Port of git_get_short_hash."""
+    full = git_get_hash(project, ref)
+    return full[:length] if full else None
+
+
+def git_get_hash(project: str, ref: str) -> str | None:
+    """Resolve ref to full OID. Port of git_get_hash (pygit2 revparse_single)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(ref)
+        return str(obj.id) if obj else None
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+def git_get_type(project: str, ref: str) -> str | None:
+    """Object type: commit, tree, blob, tag. Port of git_get_type (pygit2 obj.type)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(ref)
+        return obj.type_str if obj else None
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+def hash_set_multi(d: dict[str, Any], key: str, value: Any) -> None:
+    """Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
+    if key not in d:
+        d[key] = value
+    elif not isinstance(d[key], list):
+        d[key] = [d[key], value]
+    else:
+        d[key].append(value)
+
+
+def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, Any]:
+    """All config key/values; optionally filter by section. Port of git_parse_project_config."""
+    try:
+        repo = open_repo(project)
+        cfg = repo.config
+        result: dict[str, Any] = {}
+        for entry in cfg:
+            key = entry.name
+            if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
+                continue
+            value = entry.value
+            hash_set_multi(result, key, value)
+        return result
+    except (pygit2.GitError, OSError):
+        return {}
+
+
+def config_to_bool(val: str | None) -> bool:
+    """Config value to bool: true/yes/1. Port of config_to_bool."""
+    if val is None:
+        return True
+    val = (val or "").strip()
+    if re.match(r"^\d+$", val):
+        return int(val) != 0
+    return val.lower() in ("true", "yes")
+
+
+def config_to_int(val: str | None) -> int | str:
+    """Config value to int; k/m/g suffix. Port of config_to_int."""
+    if val is None:
+        return 0
+    val = (val or "").strip()
+    m = re.match(r"^([0-9]*)([kmg])$", val, re.I)
+    if m:
+        num, unit = m.group(1), m.group(2).lower()
+        mult = {"k": 1024, "m": 1048576, "g": 1073741824}.get(unit, 1)
+        return int(num or 0) * mult
+    return val
+
+
+def config_to_multi(val: Any) -> list[Any]:
+    """Config value to list. Port of config_to_multi."""
+    if isinstance(val, list):
+        return val
+    return [val] if val is not None else []
+
+
+# Per-repo cache for gitweb config (git_parse_project_config result)
+_config_cache: dict[str, tuple[str, dict[str, Any]]] = {}
+
+
+def git_get_project_config(
+    project: str,
+    key: str,
+    config_type: str | None = None,
+) -> str | list[str] | bool | None:
+    """Single config value; gitweb.* section. Port of git_get_project_config."""
+    key = key.lower().replace("_", "")
+    if key.startswith("gitweb."):
+        key = key[7:]
+    if re.search(r"\W", key):
+        return None
+    full_key = f"gitweb.{key}"
+    git_dir = _repo_path(project)
+    cache_key = git_dir
+    if cache_key not in _config_cache or _config_cache[cache_key][0] != os.path.join(git_dir, "config"):
+        cfg = git_parse_project_config(project, "gitweb")
+        _config_cache[cache_key] = (os.path.join(git_dir, "config"), cfg)
+    _, cfg = _config_cache[cache_key]
+    raw = cfg.get(full_key)
+    if raw is None:
+        return None
+    if config_type == "bool" or config_type == "--bool":
+        return config_to_bool(raw[0] if isinstance(raw, list) else raw)
+    if config_type == "int" or config_type == "--int":
+        return config_to_int(raw[0] if isinstance(raw, list) else raw)
+    if isinstance(raw, list):
+        return raw[0] if len(raw) == 1 else raw
+    return raw
+
+
+def git_get_hash_by_path(project: str, base: str, path: str, obj_type: str | None = None) -> str | None:
+    """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
+        path = path.rstrip("/")
+        entry = tree / path
+        if not entry:
+            return None
+        if obj_type and entry.type_str != obj_type:
+            return None
+        return str(entry.id)
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+def get_tree_at_ref_path(
+    project: str, ref: str | None, path: str | None
+) -> tuple[pygit2.Tree, str] | None:
+    """
+    Resolve the tree at ref (commit or tree) and optional path.
+    Returns (tree, ref_oid) for listing, or None if not found.
+    ref_oid is the resolved OID to use in URLs (same revision).
+    """
+    try:
+        repo = open_repo(project)
+        base_ref = ref or (str(repo.head.target) if repo.head else None)
+        if not base_ref:
+            return None
+        obj = repo.revparse_single(base_ref)
+        ref_oid = str(obj.id)
+        base_tree = obj.tree if hasattr(obj, "tree") else obj
+        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)
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+def get_blob_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Blob, str] | None:
+    """
+    Resolve the blob at ref (commit or tree) and path.
+    Returns (blob, ref_oid) or None if not found or not a blob.
+    """
+    if not path or not path.strip("/"):
+        return None
+    try:
+        repo = open_repo(project)
+        base_ref = ref or (str(repo.head.target) if repo.head else None)
+        if not base_ref:
+            return None
+        obj = repo.revparse_single(base_ref)
+        ref_oid = str(obj.id)
+        base_tree = obj.tree if hasattr(obj, "tree") else obj
+        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)
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+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
+
+        def find_in_tree(t: pygit2.Tree, prefix: str) -> str | None:
+            for e in t:
+                p = f"{prefix}{e.name}" if prefix else e.name
+                if str(e.id) == oid_str:
+                    return p
+                if e.type_str == "tree":
+                    subtree = repo[e.id]
+                    if isinstance(subtree, pygit2.Tree):
+                        found = find_in_tree(subtree, p + "/")
+                        if found:
+                            return found
+            return None
+
+        return find_in_tree(tree, "")
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+
+
+def git_get_file_or_project_config(project: str, name: str) -> str | None:
+    """Value from $GIT_DIR/name file or gitweb.name config. Port of git_get_file_or_project_config."""
+    path = os.path.join(_repo_path(project), name)
+    if os.path.isfile(path):
+        try:
+            with open(path) as f:
+                return f.read().strip()
+        except OSError:
+            pass
+    val = git_get_project_config(project, name)
+    return val[0] if isinstance(val, list) else (val if isinstance(val, str) else None)
+
+
+def git_get_project_description(project: str) -> str | None:
+    """Content of description file or config. Port of git_get_project_description."""
+    return git_get_file_or_project_config(project, "description")
+
+
+def git_get_project_category(project: str) -> str | None:
+    """Category file. Port of git_get_project_category."""
+    return git_get_file_or_project_config(project, "category")
+
+
+def git_get_references(project: str, ref_prefix: str = "refs/heads") -> list[tuple[str, str]]:
+    """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
+    except (pygit2.GitError, OSError):
+        return []
+
+
+def git_get_heads_list(project: str) -> list[tuple[str, str, str]]:
+    """List (name, ref, oid) for heads. Port of git_get_heads_list."""
+    refs = git_get_references(project, "refs/heads")
+    return [(ref.replace("refs/heads/", ""), ref, oid) for ref, oid in refs]
+
+
+def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
+    """List (name, ref, oid) for tags. Port of git_get_tags_list."""
+    refs = git_get_references(project, "refs/tags")
+    return [(ref.replace("refs/tags/", ""), ref, oid) for ref, oid in refs]
+
+
+def git_get_remotes_list(project: str) -> list[str]:
+    """Remote names. Port of git_get_remotes_list (pygit2 remotes)."""
+    try:
+        repo = open_repo(project)
+        return list(repo.remotes)
+    except (pygit2.GitError, OSError):
+        return []
+
+
+def parse_commit(project: str, oid: str) -> dict[str, Any]:
+    """Commit metadata dict. Port of parse_commit (pygit2 commit)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(oid)
+        if not isinstance(obj, pygit2.Commit):
+            return {}
+        commit = obj
+        return {
+            "parent": [str(p) for p in commit.parent_ids],
+            "tree": str(commit.tree_id),
+            "author": commit.author.name,
+            "author_email": commit.author.email,
+            "author_epoch": commit.author.time,
+            "author_tz": commit.author.offset,
+            "committer": commit.committer.name,
+            "committer_email": commit.committer.email,
+            "committer_epoch": commit.committer.time,
+            "committer_tz": commit.committer.offset,
+            "subject": commit.message.split("\n")[0] if commit.message else "",
+            "body": commit.message or "",
+        }
+    except (KeyError, pygit2.GitError, OSError):
+        return {}
+
+
+def parse_tag(project: str, oid: str) -> dict[str, Any]:
+    """Tag metadata. Port of parse_tag (pygit2 tag)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(oid)
+        if not isinstance(obj, pygit2.Tag):
+            return {}
+        tag = obj
+        return {
+            "object": str(tag.target_id),
+            "type": tag.target_type_str,
+            "tagger": tag.tagger.name if tag.tagger else "",
+            "tagger_email": tag.tagger.email if tag.tagger else "",
+            "tagger_epoch": tag.tagger.time if tag.tagger else 0,
+            "tagger_tz": tag.tagger.offset if tag.tagger else 0,
+            "message": tag.message or "",
+        }
+    except (KeyError, pygit2.GitError, OSError):
+        return {}
+
+
+def get_commit_history(
+    project: str,
+    ref: str | None = None,
+    path: str | None = None,
+    max_count: int = 100,
+) -> list[dict[str, Any]]:
+    """
+    Get commit history for a project, optionally filtered by path.
+    Returns list of commit dicts with oid and parsed commit data.
+    Port of git log functionality.
+    """
+    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
+        else:
+            # Default to HEAD
+            if repo.head:
+                start_oid = repo.head.target
+        
+        if not start_oid:
+            return []
+        
+        commits = []
+        walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
+        
+        if path:
+            # Filter by path: only commits that touched this path
+            path_clean = path.strip("/")
+            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:
+                        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
+                    pass
+        else:
+            # No path filter, get all commits
+            for commit in walker:
+                if len(commits) >= max_count:
+                    break
+                commit_data = parse_commit(project, str(commit.id))
+                commit_data["oid"] = str(commit.id)
+                commits.append(commit_data)
+        
+        return commits
+    except (KeyError, pygit2.GitError, OSError):
+        return []
diff --git a/pygitweb/main.py b/pygitweb/main.py
new file mode 100644
index 0000000..6752c55
--- /dev/null
+++ b/pygitweb/main.py
@@ -0,0 +1,574 @@
+"""
+FastAPI app and routes: gitweb actions as path operations.
+Ported from gitweb/gitweb.perl dispatch and action handlers.
+"""
+from __future__ import annotations
+
+import mimetypes
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import FastAPI, HTTPException, Query, Request
+from fastapi.responses import HTMLResponse, PlainTextResponse, Response
+from fastapi.staticfiles import StaticFiles
+
+from pygitweb import config
+from pygitweb.config import (
+    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_commit_history,
+    get_tree_at_ref_path,
+    git_get_head_hash,
+    git_get_project_description,
+    parse_commit,
+)
+from pygitweb.projects import (
+    filter_forks_from_projects_list,
+    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 urllib.parse import quote
+
+# Allowed actions (from %actions in gitweb.perl)
+ACTIONS = {
+    "blame",
+    "blame_incremental",
+    "blame_data",
+    "blobdiff",
+    "blobdiff_plain",
+    "blob",
+    "blob_plain",
+    "commitdiff",
+    "commitdiff_plain",
+    "commit",
+    "forks",
+    "heads",
+    "history",
+    "log",
+    "patch",
+    "patches",
+    "remotes",
+    "rss",
+    "atom",
+    "search",
+    "search_help",
+    "shortlog",
+    "summary",
+    "tag",
+    "tags",
+    "tree",
+    "snapshot",
+    "object",
+    "opml",
+    "project_list",
+    "project_index",
+}
+
+app = FastAPI(title="pygitweb", description="FastAPI + Pygit2 Repo Browser")
+
+# Theme: default is dark; set <html class="theme-light"> or class="theme-solarized" to switch
+HTML_HEAD = '<meta charset="utf-8"><link rel="stylesheet" href="/static/main.css">'
+
+_static_dir = Path(__file__).parent / "static"
+if _static_dir.is_dir():
+    app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
+
+
+def _project_in_list(project: str) -> bool:
+    lst = git_get_projects_list(
+        project_filter="",
+        paranoid=STRICT_EXPORT,
+        export_ok=EXPORT_OK,
+    )
+    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)
+
+
+@app.on_event("startup")
+def startup():
+    evaluate_gitweb_config()
+    configure_gitweb_features(
+        get_project_config=_get_project_config,
+        git_dir=None,
+        is_valid_ref_format=is_valid_ref_format,
+    )
+
+
+@app.middleware("http")
+async def loadavg_middleware(request: Request, call_next):
+    try:
+        check_loadavg()
+    except RuntimeError as e:
+        msg = str(e)
+        if msg.startswith("503:"):
+            raise HTTPException(status_code=503, detail=msg[4:])
+        raise
+    return await call_next(request)
+
+
+def _validate_project(project: str | None) -> str:
+    if not project:
+        raise HTTPException(status_code=400, detail="Project needed")
+    if not is_valid_project(
+        project,
+        PROJECTROOT,
+        EXPORT_OK,
+        STRICT_EXPORT,
+        _project_in_list,
+    ):
+        raise HTTPException(status_code=404, detail="No such project")
+    return project
+
+
+# ---------- Routes (no project) ----------
+
+
+@app.get("/", response_class=HTMLResponse)
+def git_project_list(
+    request: Request,
+    a: Annotated[str | None, Query(alias="a")] = None,
+    pf: Annotated[str | None, Query(alias="pf")] = None,
+    o: Annotated[str | None, Query(alias="o")] = None,
+):
+    """Project list page. Port of git_project_list."""
+    if a and a != "project_list":
+        raise HTTPException(status_code=400, detail="Unknown action")
+    if o and o not in ("none", "project", "descr", "owner", "age"):
+        raise HTTPException(status_code=400, detail="Unknown order parameter")
+    project_filter = pf or ""
+    list_ = git_get_projects_list(
+        filter_path=project_filter,
+        paranoid=STRICT_EXPORT,
+        export_ok=EXPORT_OK,
+    )
+    if not list_:
+        raise HTTPException(status_code=404, detail="No projects found")
+    
+    # todo
+    # list_ = filter_forks_from_projects_list(list_)
+    # Simple HTML response (full template can be added later)
+    rows = []
+    for pr in list_[:50]:
+        path = pr.get("path", "")
+        descr = pr.get("descr", "") or path
+        rows.append(f"<tr><td><a href='/{path}'>{esc_html(path)}</a></td><td>{esc_html(descr)}</td></tr>")
+    body = "\n".join(rows)
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{config.SITE_NAME} - project list</title>{HTML_HEAD}</head>
+<body><h1>Projects</h1><table><tr><th>Project</th><th>Description</th></tr>
+{body}
+</table></body></html>"""
+    )
+
+
+@app.get("/index", response_class=PlainTextResponse)
+def git_project_index(
+    pf: Annotated[str | None, Query(alias="pf")] = None,
+):
+    """Plain text project index (path owner). Port of git_project_index."""
+    from urllib.parse import quote_plus
+    projects = git_get_projects_list(
+        filter_path=pf or "",
+        paranoid=STRICT_EXPORT,
+        export_ok=EXPORT_OK,
+    )
+    if not projects:
+        raise HTTPException(status_code=404, detail="No projects found")
+    lines = []
+    for pr in projects:
+        path = pr.get("path", "")
+        owner = pr.get("owner") or git_get_project_owner(path) or ""
+        path_enc = quote_plus(path, safe="/")
+        owner_enc = quote_plus(owner, safe="/")
+        lines.append(f"{path_enc} {owner_enc}")
+    return PlainTextResponse("\n".join(lines), media_type="text/plain; charset=utf-8")
+
+
+@app.get("/opml", response_class=PlainTextResponse)
+def git_opml():
+    """OPML feed list. Port of git_opml (stub)."""
+    projects = git_get_projects_list(export_ok=EXPORT_OK)
+    # Minimal OPML
+    lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
+    for pr in projects[:100]:
+        path = pr.get("path", "")
+        lines.append(f'<outline text="{esc_html(path)}" />')
+    lines.append("</body></opml>")
+    return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
+
+
+# ---------- Routes (project required) ----------
+
+
+@app.get("/{project:path}", response_class=HTMLResponse)
+def dispatch(
+    request: Request,
+    project: str,
+    a: Annotated[str | None, Query(alias="a")] = None,
+    h: Annotated[str | None, Query(alias="h")] = None,
+    hb: Annotated[str | None, Query(alias="hb")] = None,
+    f: Annotated[str | None, Query(alias="f")] = None,
+):
+    """
+    Dispatch by path: /project -> summary; /project/action/... -> action.
+    Port of dispatch + run_request path handling.
+    """
+    # Normalize project: first path segment that is a valid repo
+    segments = [s for s in project.split("/") if s]
+    if not segments:
+        raise HTTPException(status_code=400, detail="Project needed")
+    proj = segments[0]
+    _validate_project(proj)
+    action = a
+    hash_param = h or hb
+    file_name = f
+    # 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}")
+            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)
+            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")
+        else:
+            action = "summary"
+    if not is_valid_action(action, ACTIONS):
+        raise HTTPException(status_code=400, detail="Unknown action")
+    if action in ("opml", "project_list", "project_index"):
+        raise HTTPException(status_code=400, detail="Project not needed for this action")
+    # Route to handler
+    if action == "summary":
+        return git_summary(proj)
+    if action == "forks":
+        return git_forks(proj, request)
+    if action == "tree":
+        return git_tree(proj, hash_param, file_name)
+    if action in ("blob", "blob_plain"):
+        return git_blob(proj, hash_param, file_name, raw=(action == "blob_plain"))
+    if action == "log":
+        return git_log(proj, hash_param)
+    if action == "shortlog":
+        return git_shortlog(proj, hash_param)
+    if action == "history":
+        return git_history(proj, hash_param, file_name)
+    # Stub others with minimal response
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{esc_html(action)} - {esc_html(proj)}</title>{HTML_HEAD}</head>
+<body><p>Action: {esc_html(action)}</p><p>Project: {esc_html(proj)}</p></body></html>"""
+    )
+
+
+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_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 ""
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{config.SITE_NAME} - {project}</title>{HTML_HEAD}</head>
+<body><h1>{esc_html(project)}</h1>
+<table><tr><td>description</td><td>{esc_html(descr)}</td></tr>
+<tr><td>owner</td><td>{esc_html(owner)}</td></tr>
+<tr><td>HEAD</td><td><a href="/{project}/commit/{head or ''}">{head_short or 'N/A'}</a></td></tr>
+<tr><td>tree</td><td><a href="/{project}?a=tree&h={head or ''}">browse</a></td></tr>
+</table></body></html>"""
+    )
+
+
+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
+    text = to_utf8(data) or ""
+    body_escaped = sanitize(text) or ""
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{esc_html(f)} - {esc_html(project)}</title>{HTML_HEAD}</head>
+<body><pre class="blob-content">{body_escaped}</pre></body></html>"""
+    )
+
+
+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'<tr><td><a href="{link}">{esc_html(name)}/</a></td><td>tree</td></tr>'
+        )
+    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'<tr><td><a href="{link}">{esc_html(name)}</a></td><td>blob</td></tr>'
+        )
+    table_body = "\n".join(rows)
+    title_path = f" / {f}" if f else ""
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{esc_html(project)}{esc_html(title_path)} - tree</title>{HTML_HEAD}</head>
+<body><p class="breadcrumb">{breadcrumb_html}</p>
+<h1>Tree{esc_html(title_path)}</h1>
+<table><thead><tr><th>Name</th><th>Type</th></tr></thead>
+<tbody>
+{table_body}
+</tbody></table></body></html>"""
+    )
+
+
+def git_forks(project: str, request: Request) -> HTMLResponse:
+    """Forks of project. Port of git_forks."""
+    filter_path = project.replace(".git", "")
+    list_ = git_get_projects_list(filter_path=filter_path, export_ok=EXPORT_OK)
+    if not list_:
+        raise HTTPException(status_code=404, detail="No forks found")
+    list_ = filter_forks_from_projects_list(list_)
+    rows = []
+    for pr in list_:
+        path = pr.get("path", "")
+        rows.append(f"<tr><td><a href='/{path}'>{esc_html(path)}</a></td></tr>")
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>Forks - {esc_html(project)}</title>{HTML_HEAD}</head>
+<body><h1>Forks of {esc_html(project)}</h1><table>{"".join(rows)}</table></body></html>"""
+    )
+
+
+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='')}"
+    author_display = esc_html(author) or "unknown"
+    
+    if short:
+        # Shortlog: simpler format
+        return (
+            f'<tr>'
+            f'<td><a href="{commit_link}">{esc_html(oid_short)}</a></td>'
+            f'<td>{esc_html(subject)}</td>'
+            f'<td>{author_display}</td>'
+            f'<td>{esc_html(age_str)}</td>'
+            f'</tr>'
+        )
+    else:
+        # Full log: more details
+        return (
+            f'<tr>'
+            f'<td><a href="{commit_link}">{esc_html(oid_short)}</a></td>'
+            f'<td>{esc_html(subject)}</td>'
+            f'<td>{author_display} &lt;{esc_html(author_email)}&gt;</td>'
+            f'<td>{esc_html(date_str)}</td>'
+            f'<td>{esc_html(age_str)}</td>'
+            f'</tr>'
+        )
+
+
+def git_log(project: str, h: str | None) -> HTMLResponse:
+    """Commit log page. Port of git_log."""
+    commits = get_commit_history(project, ref=h, max_count=100)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    
+    table_body = "\n".join(rows)
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Log - {esc_html(project)}"
+    if h:
+        title += f" @ {esc_html(ref_display)}"
+    
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{title}</title>{HTML_HEAD}</head>
+<body><h1>Commit log - {esc_html(project)}</h1>
+<table>
+<thead>
+<tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Age</th></tr>
+</thead>
+<tbody>
+{table_body}
+</tbody>
+</table></body></html>"""
+    )
+
+
+def git_shortlog(project: str, h: str | None) -> HTMLResponse:
+    """Shortlog page. Port of git_shortlog."""
+    commits = get_commit_history(project, ref=h, max_count=100)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=True))
+    
+    table_body = "\n".join(rows)
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Shortlog - {esc_html(project)}"
+    if h:
+        title += f" @ {esc_html(ref_display)}"
+    
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>{title}</title>{HTML_HEAD}</head>
+<body><h1>Shortlog - {esc_html(project)}</h1>
+<table>
+<thead>
+<tr><th>Commit</th><th>Subject</th><th>Author</th><th>Age</th></tr>
+</thead>
+<tbody>
+{table_body}
+</tbody>
+</table></body></html>"""
+    )
+
+
+def git_history(project: str, h: str | None, f: str | None) -> 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")
+    
+    commits = get_commit_history(project, ref=h, path=f, max_count=100)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No history found for this path")
+    
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    
+    table_body = "\n".join(rows)
+    ref_display = h[:7] if h else "HEAD"
+    
+    return HTMLResponse(
+        f"""<!DOCTYPE html><html class="theme-dark"><head><title>History - {esc_html(f)} - {esc_html(project)}</title>{HTML_HEAD}</head>
+<body><h1>History of {esc_html(f)}</h1>
+<p>Project: <a href="/{project}">{esc_html(project)}</a></p>
+<table>
+<thead>
+<tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Age</th></tr>
+</thead>
+<tbody>
+{table_body}
+</tbody>
+</table></body></html>"""
+    )
+
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/pygitweb/projects.py b/pygitweb/projects.py
new file mode 100644
index 0000000..ee54aff
--- /dev/null
+++ b/pygitweb/projects.py
@@ -0,0 +1,247 @@
+"""
+Project list: get_projects_list, filter_forks, search_projects_list, project_in_list,
+get_project_owner, get_project_list_from_file, get_last_activity.
+Ported from gitweb/gitweb.perl.
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from typing import Any, Callable
+
+from pygitweb.config import PROJECTROOT, PROJECTS_LIST, PROJECT_MAXDEPTH, LIST_ALL
+from pygitweb.validation import check_export_ok
+
+
+def _export_ok_path(git_dir: str, export_ok: str) -> bool:
+    return not export_ok or os.path.isfile(os.path.join(git_dir, export_ok))
+
+
+def project_in_list(
+    project: str,
+    get_projects_list_fn: Callable[[], list[dict[str, Any]]],
+) -> bool:
+    """True if project appears in project list. Port of project_in_list."""
+    lst = get_projects_list_fn()
+    return any(p.get("path") == project for p in lst)
+
+
+def _find_projects_in_dir(
+    root: str,
+    prefix_len: int,
+    prefix_depth: int,
+    maxdepth: int,
+    export_ok: str,
+    export_auth_hook: Callable[[str], bool] | None,
+    skip_export_check: bool = False,
+) -> list[dict[str, Any]]:
+    result = []
+    for dirpath, dirnames, _ in os.walk(root, topdown=True):
+        rel = os.path.relpath(dirpath, root)
+        if rel == ".":
+            depth = 0
+        else:
+            depth = rel.count(os.sep) + 1
+        if depth > maxdepth:
+            dirnames.clear()
+            continue
+        for d in list(dirnames):
+            path = os.path.join(dirpath, d)
+            if not os.path.isdir(path):
+                continue
+            try:
+                if not os.access(path, os.X_OK):
+                    continue
+            except OSError:
+                continue
+            project_path = os.path.relpath(path, PROJECTROOT)
+            project_path = project_path.replace("\\", "/")
+            git_dir = os.path.join(PROJECTROOT, project_path)
+            if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
+                continue
+            result.append({"path": project_path})
+            dirnames.remove(d)
+    return result
+
+
+def git_get_projects_list(
+    filter_path: str = "",
+    paranoid: bool = False,
+    projectroot: str = PROJECTROOT,
+    projects_list: str = PROJECTS_LIST,
+    project_maxdepth: int = PROJECT_MAXDEPTH,
+    export_ok: str = "",
+    export_auth_hook: Callable[[str], bool] | None = None,
+) -> list[dict[str, Any]]:
+    """List projects from directory scan or file. Port of git_get_projects_list."""
+    if os.path.isdir(projects_list):
+        root = projects_list.rstrip("/")
+        prefix_len = len(root) + 1
+        prefix_depth = root.count(os.sep)
+        if filter_path and not paranoid:
+            root = os.path.join(root, filter_path).rstrip("/")
+        result = _find_projects_in_dir(
+            root, prefix_len, prefix_depth, project_maxdepth,
+            export_ok, export_auth_hook,
+            skip_export_check=LIST_ALL,
+        )
+        if filter_path and paranoid:
+            result = [p for p in result if p["path"].startswith(filter_path + "/")]
+        return result
+    if os.path.isfile(projects_list):
+        from urllib.parse import unquote
+        result = []
+        with open(projects_list) as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                parts = line.split(None, 1)
+                path = unquote(parts[0]) if parts else ""
+                owner = unquote(parts[1]) if len(parts) > 1 else None
+                if not path:
+                    continue
+                if filter_path and not path.startswith(filter_path + "/"):
+                    continue
+                git_dir = os.path.join(projectroot, path)
+                if not LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
+                    continue
+                pr = {"path": path}
+                if owner:
+                    pr["owner"] = owner
+                result.append(pr)
+        return result
+    return []
+
+
+_gitweb_project_owner: dict[str, str] | None = None
+
+
+def git_get_project_list_from_file(
+    projects_list: str = PROJECTS_LIST,
+    projectroot: str = PROJECTROOT,
+) -> dict[str, str]:
+    """Load project -> owner from file. Port of git_get_project_list_from_file."""
+    global _gitweb_project_owner
+    if _gitweb_project_owner is not None:
+        return _gitweb_project_owner
+    _gitweb_project_owner = {}
+    if os.path.isfile(projects_list):
+        from urllib.parse import unquote
+        with open(projects_list) as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                parts = line.split(None, 1)
+                path = unquote(parts[0]) if parts else ""
+                owner = unquote(parts[1]) if len(parts) > 1 else ""
+                if path:
+                    _gitweb_project_owner[path] = owner
+    return _gitweb_project_owner
+
+
+def git_get_project_owner(
+    project: str,
+    projectroot: str = PROJECTROOT,
+    get_project_config: Callable[[str, str], Any] | None = None,
+) -> str | None:
+    """Owner from list file or config or file ownership. Port of git_get_project_owner."""
+    if not project:
+        return None
+    owners = git_get_project_list_from_file(PROJECTS_LIST, projectroot)
+    if project in owners and owners[project]:
+        return owners[project]
+    if get_project_config:
+        val = get_project_config(project, "owner")
+        if val:
+            return val[0] if isinstance(val, list) else val
+    git_dir = os.path.join(projectroot, project)
+    try:
+        stat = os.stat(git_dir)
+        import pwd
+        return pwd.getpwuid(stat.st_uid).pw_gecos or pwd.getpwuid(stat.st_uid).pw_name
+    except (OSError, KeyError):
+        return None
+
+
+def git_get_last_activity(project: str, projectroot: str = PROJECTROOT) -> int | None:
+    """Last commit timestamp for project. Port of git_get_last_activity."""
+    from pygitweb.git_helpers import git_get_head_hash, parse_commit
+
+    oid = git_get_head_hash(project)
+    if not oid:
+        return None
+    co = parse_commit(project, oid)
+    return co.get("committer_epoch")
+
+
+def filter_forks_from_projects_list(
+    projects: list[dict[str, Any]],
+    projectroot: str = PROJECTROOT,
+) -> list[dict[str, Any]]:
+    """Remove forks from list; set 'forks' on each project. Port of filter_forks_from_projects_list."""
+    trie: dict[str, Any] = {}
+    for pr in projects:
+        path = pr.get("path", "")
+        path_no_git = path.replace(".git", "")
+        if path_no_git.endswith("/") or not path_no_git:
+            pr["forks"] = []
+            continue
+        if not os.path.isdir(os.path.join(projectroot, path)):
+            pr["forks"] = []
+            continue
+        pr["forks"] = []
+        dirs = path_no_git.split("/")
+        ref = trie
+        for d in dirs:
+            ref = ref.setdefault(d, {})
+        ref[""] = pr
+
+    filtered = []
+    for pr in projects:
+        path = pr.get("path", "")
+        dirs = path.split("/")
+        ref = trie
+        for d in dirs:
+            if "" in ref:
+                ref[""].setdefault("forks", []).append(pr)
+                break
+            if d not in ref:
+                filtered.append(pr)
+                break
+            ref = ref[d]
+        else:
+            if "" not in ref:
+                filtered.append(pr)
+    return filtered
+
+
+def search_projects_list(
+    projlist: list[dict[str, Any]],
+    tagfilter: str | None = None,
+    search_regexp: str | None = None,
+    fill_project_list_info: Callable[..., None] | None = None,
+) -> list[dict[str, Any]]:
+    """Filter by tag or search regex. Port of search_projects_list."""
+    if not tagfilter and not search_regexp:
+        return projlist
+    if fill_project_list_info:
+        fill_project_list_info(projlist, tagfilter=tagfilter, search_re=search_regexp)
+    result = []
+    for pr in projlist:
+        if tagfilter:
+            ctags = pr.get("ctags") or {}
+            if not any(k.lower() == tagfilter.lower() for k in ctags):
+                continue
+        if search_regexp:
+            try:
+                rex = re.compile(search_regexp)
+            except re.error:
+                continue
+            descr = (pr.get("descr_long") or "") + (pr.get("path") or "")
+            if not rex.search(descr):
+                continue
+        result.append(pr)
+    return result
diff --git a/pygitweb/requirements.txt b/pygitweb/requirements.txt
new file mode 100644
index 0000000..084b012
--- /dev/null
+++ b/pygitweb/requirements.txt
@@ -0,0 +1,7 @@
+# Pygitweb: Gitweb reimplementation with FastAPI and Pygit2
+fastapi[standard-no-fastapi-cloud-cli]>=0.104.0
+uvicorn[standard]>=0.24.0
+python-multipart>=0.0.6
+jinja2>=3.1.0
+orjson>=0.19.0
+pygit2>=1.12.0
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
new file mode 100644
index 0000000..ce4d00a
--- /dev/null
+++ b/pygitweb/static/main.css
@@ -0,0 +1,97 @@
+@import "./themes/light.css";
+
+html {
+	box-sizing: border-box;
+}
+*, *::before, *::after {
+	box-sizing: inherit;
+}
+
+body {
+	font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+	font-size: 14px;
+	line-height: 1.5;
+	margin: 0;
+	padding: 12px 16px;
+	background-color: var(--pgw-bg);
+	color: var(--pgw-text);
+	border: 1px solid var(--pgw-border);
+}
+
+a {
+	color: var(--pgw-link);
+	text-decoration: none;
+}
+a:hover {
+	color: var(--pgw-link-hover);
+}
+a:visited {
+	color: var(--pgw-link-visited);
+}
+
+h1, h2, h3 {
+	margin: 0 0 0.5em;
+	color: var(--pgw-accent-text);
+	font-weight: 600;
+}
+
+/* Tables */
+table {
+	border-collapse: collapse;
+	border-spacing: 0;
+	width: 100%;
+	max-width: 60em;
+	margin: 0.5em 0;
+	background-color: var(--pgw-bg-raised);
+	border: 1px solid var(--pgw-border);
+}
+
+th, td {
+	padding: 6px 10px;
+	text-align: left;
+	vertical-align: top;
+	border-bottom: 1px solid var(--pgw-border-muted);
+}
+th {
+	background-color: var(--pgw-accent-bg);
+	color: var(--pgw-accent-text);
+	font-weight: 600;
+}
+tbody tr:nth-child(even) {
+	background-color: var(--pgw-table-stripe);
+}
+tbody tr:hover {
+	background-color: var(--pgw-table-hover);
+}
+
+/* Optional: page header / footer blocks for future use */
+.page_header,
+.page_footer {
+	padding: 8px 12px;
+	background-color: var(--pgw-accent-bg);
+	color: var(--pgw-accent-text);
+	border: 1px solid var(--pgw-border);
+}
+.page_footer_text {
+	color: var(--pgw-text-muted);
+	font-style: italic;
+}
+.page_body {
+	padding: 8px;
+	font-family: ui-monospace, monospace;
+	background-color: var(--pgw-code-bg);
+	border: 1px solid var(--pgw-code-border);
+}
+.title, .title_text {
+	background-color: var(--pgw-title-bg);
+	padding: 6px 8px;
+	border-bottom: 1px solid var(--pgw-border);
+}
+.title:hover, a.title:hover {
+	background-color: var(--pgw-title-hover-bg);
+}
+
+/* Diff/status helpers (use var(--pgw-add-*) etc. in markup if needed) */
+.diff-add { color: var(--pgw-add-fg); background-color: var(--pgw-add-bg); }
+.diff-rem { color: var(--pgw-rem-fg); background-color: var(--pgw-rem-bg); }
+.text-muted { color: var(--pgw-text-muted); }
diff --git a/pygitweb/static/themes/dark.css b/pygitweb/static/themes/dark.css
new file mode 100644
index 0000000..2fd8805
--- /dev/null
+++ b/pygitweb/static/themes/dark.css
@@ -0,0 +1,39 @@
+:root, .theme-dark {
+	/* Surfaces */
+	--pgw-bg: #1a1a1e;
+	--pgw-bg-raised: #25252c;
+	--pgw-bg-muted: #2d2d36;
+	--pgw-border: #3d3d48;
+	--pgw-border-muted: #35353d;
+
+	/* Text */
+	--pgw-text: #e4e4e7;
+	--pgw-text-muted: #a1a1aa;
+	--pgw-text-dim: #71717a;
+
+	/* Links */
+	--pgw-link: #7dd3fc;
+	--pgw-link-visited: #c4b5fd;
+	--pgw-link-hover: #fda4af;
+
+	/* Accents (headers, table headers, emphasis) */
+	--pgw-accent-bg: #27272a;
+	--pgw-accent-text: #fafafa;
+	--pgw-title-bg: #2d2d36;
+	--pgw-title-hover-bg: #3d3d48;
+
+	/* Code / monospace */
+	--pgw-code-bg: #18181b;
+	--pgw-code-border: #27272a;
+
+	/* Tables */
+	--pgw-table-stripe: #222226;
+	--pgw-table-hover: #2d2d36;
+
+	/* Diff / status (optional) */
+	--pgw-add-fg: #86efac;
+	--pgw-add-bg: #14532d;
+	--pgw-rem-fg: #fca5a5;
+	--pgw-rem-bg: #7f1d1d;
+	--pgw-info-fg: #a1a1aa;
+}
diff --git a/pygitweb/static/themes/light.css b/pygitweb/static/themes/light.css
new file mode 100644
index 0000000..93f97f5
--- /dev/null
+++ b/pygitweb/static/themes/light.css
@@ -0,0 +1,32 @@
+:root, .theme-light {
+	--pgw-bg: #fafafa;
+	--pgw-bg-raised: #ffffff;
+	--pgw-bg-muted: #f4f4f5;
+	--pgw-border: #e4e4e7;
+	--pgw-border-muted: #d4d4d8;
+
+	--pgw-text: #18181b;
+	--pgw-text-muted: #52525b;
+	--pgw-text-dim: #71717a;
+
+	--pgw-link: #2563eb;
+	--pgw-link-visited: #7c3aed;
+	--pgw-link-hover: #dc2626;
+
+	--pgw-accent-bg: #e4e4e7;
+	--pgw-accent-text: #18181b;
+	--pgw-title-bg: #f4f4f5;
+	--pgw-title-hover-bg: #e4e4e7;
+
+	--pgw-code-bg: #f4f4f5;
+	--pgw-code-border: #e4e4e7;
+
+	--pgw-table-stripe: #fafafa;
+	--pgw-table-hover: #f4f4f5;
+
+	--pgw-add-fg: #166534;
+	--pgw-add-bg: #dcfce7;
+	--pgw-rem-fg: #991b1b;
+	--pgw-rem-bg: #fee2e2;
+	--pgw-info-fg: #52525b;
+}
diff --git a/pygitweb/static/themes/solarized.css b/pygitweb/static/themes/solarized.css
new file mode 100644
index 0000000..6469376
--- /dev/null
+++ b/pygitweb/static/themes/solarized.css
@@ -0,0 +1,32 @@
+:root, .theme-solarized {
+	--pgw-bg: #002b36;
+	--pgw-bg-raised: #073642;
+	--pgw-bg-muted: #0d3d4a;
+	--pgw-border: #586e75;
+	--pgw-border-muted: #465c63;
+
+	--pgw-text: #839496;
+	--pgw-text-muted: #657b83;
+	--pgw-text-dim: #586e75;
+
+	--pgw-link: #268bd2;
+	--pgw-link-visited: #6c71c4;
+	--pgw-link-hover: #dc322f;
+
+	--pgw-accent-bg: #073642;
+	--pgw-accent-text: #93a1a1;
+	--pgw-title-bg: #073642;
+	--pgw-title-hover-bg: #0d3d4a;
+
+	--pgw-code-bg: #002b36;
+	--pgw-code-border: #073642;
+
+	--pgw-table-stripe: #073642;
+	--pgw-table-hover: #0d3d4a;
+
+	--pgw-add-fg: #859900;
+	--pgw-add-bg: #073642;
+	--pgw-rem-fg: #dc322f;
+	--pgw-rem-bg: #4a0a0a;
+	--pgw-info-fg: #657b83;
+}
diff --git a/pygitweb/validation.py b/pygitweb/validation.py
new file mode 100644
index 0000000..9f06b40
--- /dev/null
+++ b/pygitweb/validation.py
@@ -0,0 +1,118 @@
+"""
+Validation: pathname, ref format, refname, project, action; repo discovery via pygit2.
+Ported from gitweb/gitweb.perl (is_valid_pathname, is_valid_ref_format, is_valid_refname,
+is_valid_project, is_valid_action). Repo check uses pygit2.discover_repository..
+"""
+from __future__ import annotations
+
+import os
+import re
+from typing import Callable
+
+import pygit2
+
+# OID regex: 40 hex (SHA-1) or 40+24 (SHA-256). Port of $oid_regex / oid_nlen_regex.
+OID_PATTERN = re.compile(r"^[0-9a-fA-F]{7,64}$")
+SHA1_LEN = 40
+SHA256_EXTRA = 24
+
+
+def oid_nlen_regex(length: int | str) -> re.Pattern[str]:
+    """Regex matching exactly `length` hex chars. Port of oid_nlen_regex."""
+    if isinstance(length, str) and "-" in length:
+        lo, hi = length.split("-")
+        return re.compile(f"^[0-9a-fA-F]{{{int(lo)},{int(hi)}}}$")
+    n = int(length)
+    return re.compile(f"^[0-9a-fA-F]{{{n}}}$")
+
+
+def oid_nlen_prefix_infix_regex(nlen: int, prefix: str, infix: str) -> re.Pattern[str]:
+    """Two OID-like groups with literal prefix and infix. Port of oid_nlen_prefix_infix_regex."""
+    rx = oid_nlen_regex(nlen)
+    return re.compile(f"^{re.escape(prefix)}{rx.pattern}{re.escape(infix)}{rx.pattern}$")
+
+
+def is_valid_pathname(input_path: str | None) -> bool:
+    """No '.', '..' as path elements, no null, no doubled slashes. Port of is_valid_pathname."""
+    if input_path is None:
+        return False
+    if "\0" in input_path:
+        return False
+    parts = input_path.strip("/").split("/")
+    for p in parts:
+        if p in ("", ".", ".."):
+            return False
+    return True
+
+
+def is_valid_ref_format(input_ref: str | None) -> bool:
+    """Git-check-ref-format rules: no /., no .., no control/space/special at start/end. Port of is_valid_ref_format."""
+    if input_ref is None:
+        return False
+    if "/." in input_ref or input_ref.startswith(".") or ".." in input_ref:
+        return False
+    if input_ref.endswith("/") or input_ref.endswith(".lock"):
+        return False
+    # No ASCII control, space, ~^:?*[
+    if re.search(r"[\x00-\x20\x7f ~^:?*\[\]]", input_ref):
+        return False
+    return True
+
+
+def is_valid_refname(input_ref: str | None) -> bool:
+    """Either full OID hex or valid pathname + ref format. Port of is_valid_refname."""
+    if input_ref is None:
+        return False
+    if OID_PATTERN.match(input_ref):
+        return True
+    return is_valid_pathname(input_ref) and is_valid_ref_format(input_ref)
+
+
+def check_export_ok(
+    git_dir: str,
+    export_ok: str = "",
+    export_auth_hook: Callable[[str], bool] | None = None,
+    across_fs: bool = False,
+) -> bool:
+    """True if path is a git repo (via pygit2.discover_repository) and optional export_ok file / auth hook pass."""
+    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
+    except (KeyError, pygit2.GitError, OSError):
+        return False
+    if export_ok and not os.path.isfile(os.path.join(git_dir, export_ok)):
+        return False
+    if export_auth_hook is not None and not export_auth_hook(git_dir):
+        return False
+    return True
+
+
+def is_valid_action(action: str | None, allowed_actions: set[str]) -> bool:
+    """Action is in allowed set. Port of is_valid_action."""
+    return action in allowed_actions if action else False
+
+
+def is_valid_project(
+    project: str | None,
+    projectroot: str,
+    export_ok: str,
+    strict_export: bool,
+    project_in_list: Callable[[str], bool],
+) -> bool:
+    """Pathname valid, dir exists, export_ok, and (if strict) in project list. Port of is_valid_project."""
+    if project is None:
+        return False
+    if not is_valid_pathname(project):
+        return False
+    full = os.path.join(projectroot, project)
+    if not os.path.isdir(full):
+        return False
+    if not check_export_ok(full, export_ok):
+        return False
+    if strict_export and not project_in_list(project):
+        return False
+    return True
