diff --git a/pygitweb/README.md b/pygitweb/README.md
index 4662d20..74d7aca 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -52,6 +52,7 @@ See [ROUTES.md](ROUTES.md) for more detail.
 - `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
+- `GET /{project}?a=heads` — list branch heads
 - `GET /{project}?a=tags` — list all tags
 - `GET /{project}?a=tag&h=...` — single tag view (tag ref or hash)
 - `GET /{project}?a=commit` — commit information
@@ -61,7 +62,7 @@ See [ROUTES.md](ROUTES.md) for more detail.
 
 **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`, `heads`, `patch`, `patches`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
+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`, `patch`, `patches`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
 
 ## Query parameter short names (CGI mapping)
 
diff --git a/pygitweb/main.py b/pygitweb/main.py
index ba76f4e..cd929b4 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -44,6 +44,7 @@ from pygitweb.git_helpers import (
     get_commit_history,
     get_tree_at_ref_path,
     git_get_head_hash,
+    git_get_heads_list,
     git_get_project_description,
     git_get_remotes_info,
     git_get_tags_list,
@@ -471,6 +472,8 @@ def dispatch(
     if action == "history":
         p, pc = _parse_pagination(page, pagecount)
         return git_history(proj, hash_param, file_name, request, p, pc)
+    if action == "heads":
+        return git_heads(proj)
     if action == "tags":
         p, pc = _parse_pagination(page, pagecount)
         return git_tags(proj, request, p, pc)
@@ -568,6 +571,7 @@ def git_summary(project: str) -> HTMLResponse:
             [esc_html("tree"), f"<a href='/{project}?a=tree&h={head or ''}'>browse</a>"],
             ["Log", f"<a href='/{project}?a=log&h={head or ''}'>view log</a>"],
             ["Shortlog", f"<a href='/{project}?a=shortlog&h={head or ''}'>view shortlog</a>"],
+            ["Heads", f"<a href='/{project}?a=heads'>view heads</a>"],
             ["Tags", f"<a href='/{project}?a=tags'>view tags</a>"],
             ["Remotes", f"<a href='/{project}?a=remotes'>view remotes</a>"],
         ]
@@ -880,6 +884,32 @@ def git_history(project: str, h: str | None, f: str | None, request: Request, pa
     )
 
 
+def git_heads(project: str) -> HTMLResponse:
+    """Heads (branches) list page. Port of git_heads."""
+    heads_list = git_get_heads_list(project)
+    if not heads_list:
+        raise HTTPException(status_code=404, detail="No heads found")
+    rows = []
+    for name, _ref, oid in heads_list:
+        oid_short = oid[:7] if oid else ""
+        commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
+        tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
+        rows.append([
+            f'<a href="{commit_link}">{esc_html(name)}</a>',
+            f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
+            f'<a href="{tree_link}">tree</a>',
+        ])
+    title = f"Heads - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Head", "Commit", ""],
+        rows=rows,
+    )
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
 def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
     """Tags list page. Port of git_tags."""
     tags_list = git_get_tags_list(project)
