a73x

5bbde888

Fix review issues: SSH push, security hardening, split repo handlers, strip CSS

a73x   2026-03-31 14:09

- Fix SSH git-receive-pack by forwarding client stdin via mpsc channel
- Fix broken commit link in overview template (/commits/ → /diff/)
- Set host key file permissions to 0600 on generation
- Add canonicalize check in resolve_repo_path to prevent symlink escapes
- Split monolithic repo.rs (940 lines) into focused handler modules
- Add explicit 10 MiB body size limit on git-upload-pack POST endpoint
- Add caching trade-off comments on per-request scanning helpers
- Extract open_repo helper to reduce handler boilerplate
- Strip all CSS and footer for raw HTML output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/Cargo.lock b/Cargo.lock
index 9c2233b..9380d22 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3199,6 +3199,15 @@ dependencies = [
]

[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
 "serde",
]

[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3706,6 +3715,9 @@ name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
 "serde",
]

[[package]]
name = "toml_edit"
@@ -3714,6 +3726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
 "indexmap",
 "serde",
 "serde_spanned",
 "toml_datetime",
 "toml_write",
 "winnow 0.7.15",
diff --git a/src/server/http/git_http.rs b/src/server/http/git_http.rs
index fba17f7..d3eb6ce 100644
--- a/src/server/http/git_http.rs
+++ b/src/server/http/git_http.rs
@@ -9,6 +9,11 @@ use tokio::process::Command;

use super::AppState;

/// Max request body for git upload-pack negotiation (10 MiB).
/// Git smart HTTP negotiation payloads are typically small (a few KiB of want/have lines),
/// but we allow headroom for repos with many refs.
pub const UPLOAD_PACK_BODY_LIMIT: usize = 10 * 1024 * 1024;

#[derive(serde::Deserialize)]
pub struct InfoRefsQuery {
    pub service: Option<String>,
diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs
index 3db51a2..1c6c189 100644
--- a/src/server/http/mod.rs
+++ b/src/server/http/mod.rs
@@ -4,6 +4,7 @@ pub mod repo;

use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::DefaultBodyLimit;
use axum::Router;

#[derive(Debug, Clone)]
@@ -27,6 +28,7 @@ pub fn router(state: AppState) -> Router {
        .route("/{repo_name}/issues", axum::routing::get(repo::issues))
        .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail))
        .route("/{repo_dot_git}/info/refs", axum::routing::get(git_http::info_refs))
        .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack))
        .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack)
            .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT)))
        .with_state(shared)
}
diff --git a/src/server/http/repo.rs b/src/server/http/repo.rs
deleted file mode 100644
index 0be0628..0000000
--- a/src/server/http/repo.rs
+++ /dev/null
@@ -1,943 +0,0 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use chrono::{TimeZone, Utc};

use super::AppState;

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "error.html")]
pub struct ErrorTemplate {
    pub site_title: String,
    pub status_code: u16,
    pub message: String,
}

fn not_found(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 404,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::NOT_FOUND;
    response
}

fn internal_error(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 500,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
    response
}

#[derive(Debug)]
pub struct OverviewCommit {
    pub id: String,
    pub short_id: String,
    pub summary: String,
    pub author: String,
    pub date: String,
}

#[derive(Debug)]
pub struct OverviewPatch {
    pub id: String,
    pub title: String,
    pub author: String,
}

#[derive(Debug)]
pub struct OverviewIssue {
    pub id: String,
    pub title: String,
    pub author: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "repo_overview.html")]
pub struct OverviewTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub commits: Vec<OverviewCommit>,
    pub patches: Vec<OverviewPatch>,
    pub issues: Vec<OverviewIssue>,
}

fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> {
    let mut revwalk = match repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return Vec::new(),
    };

    if revwalk.push_head().is_err() {
        return Vec::new();
    }

    revwalk
        .take(limit)
        .filter_map(|oid| {
            let oid = oid.ok()?;
            let commit = repo.find_commit(oid).ok()?;
            let id = oid.to_string();
            let short_id = id[..8.min(id.len())].to_string();
            let summary = commit.summary().unwrap_or("").to_string();
            let author = commit.author().name().unwrap_or("").to_string();
            let secs = commit.time().seconds();
            let date = Utc
                .timestamp_opt(secs, 0)
                .single()
                .map(|dt| dt.format("%Y-%m-%d").to_string())
                .unwrap_or_default();
            Some(OverviewCommit { id, short_id, summary, author, date })
        })
        .collect()
}

fn collab_counts(repo: &git2::Repository) -> (usize, usize) {
    let open_patches = git_collab::state::list_patches(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .count();

    let open_issues = git_collab::state::list_issues(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .count();

    (open_patches, open_issues)
}

pub async fn overview(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let commits = recent_commits(&repo, 10);
    let (open_patches, open_issues) = collab_counts(&repo);

    let patches = git_collab::state::list_patches(&repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .map(|p| OverviewPatch {
            id: p.id,
            title: p.title,
            author: p.author.name,
        })
        .collect();

    let issues = git_collab::state::list_issues(&repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .map(|i| OverviewIssue {
            id: i.id,
            title: i.title,
            author: i.author.name,
        })
        .collect();

    OverviewTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "overview".to_string(),
        open_patches,
        open_issues,
        commits,
        patches,
        issues,
    }
    .into_response()
}

// ── Task 8: Commits page ──────────────────────────────────────────────────────

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "commits.html")]
pub struct CommitsTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub commits: Vec<OverviewCommit>,
}

pub async fn commits(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let commits = recent_commits(&repo, 200);
    let (open_patches, open_issues) = collab_counts(&repo);

    CommitsTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "commits".to_string(),
        open_patches,
        open_issues,
        commits,
    }
    .into_response()
}

// ── Task 9: Tree and blob pages ───────────────────────────────────────────────

#[derive(Debug)]
pub struct TreeEntry {
    pub name: String,
    pub full_path: String,
    pub is_dir: bool,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "tree.html")]
pub struct TreeTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub ref_name: String,
    pub path_display: String,
    pub show_parent: bool,
    pub parent_path: String,
    pub entries: Vec<TreeEntry>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "blob.html")]
pub struct BlobTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub ref_name: String,
    pub file_path: String,
    pub content: String,
    pub is_binary: bool,
    pub size_display: String,
}

/// Split "ref/path/to/file" — first segment is ref, rest joined is path.
/// Returns ("HEAD", "") for empty input.
fn split_ref_path(input: &str) -> (String, String) {
    if input.is_empty() {
        return ("HEAD".to_string(), String::new());
    }
    let parts: Vec<&str> = input.splitn(2, '/').collect();
    let ref_name = parts[0].to_string();
    let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() };
    (ref_name, path)
}

fn build_tree_response(
    state: &AppState,
    repo_name: String,
    repo: &git2::Repository,
    ref_name: String,
    path: String,
) -> Response {
    let (open_patches, open_issues) = collab_counts(repo);

    // Resolve the ref to a tree
    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)),
    };

    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return not_found(state, "Could not resolve ref to a commit."),
    };

    let root_tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return internal_error(state, "Failed to get commit tree."),
    };

    // Navigate into subdirectory if path is non-empty
    let tree = if path.is_empty() {
        root_tree
    } else {
        let entry = match root_tree.get_path(std::path::Path::new(&path)) {
            Ok(e) => e,
            Err(_) => return not_found(state, format!("Path '{}' not found.", path)),
        };
        let obj = match entry.to_object(repo) {
            Ok(o) => o,
            Err(_) => return internal_error(state, "Failed to resolve path object."),
        };
        match obj.into_tree() {
            Ok(t) => t,
            Err(_) => return not_found(state, "Path is not a directory."),
        }
    };

    let mut entries: Vec<TreeEntry> = tree
        .iter()
        .map(|e| {
            let name = e.name().unwrap_or("").to_string();
            let full_path = if path.is_empty() {
                name.clone()
            } else {
                format!("{}/{}", path, name)
            };
            let is_dir = e.kind() == Some(git2::ObjectType::Tree);
            TreeEntry { name, full_path, is_dir }
        })
        .collect();

    // Sort dirs first, then files, alphabetically within each group
    entries.sort_by(|a, b| {
        match (a.is_dir, b.is_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.cmp(&b.name),
        }
    });

    let (show_parent, parent_path) = if path.is_empty() {
        (false, String::new())
    } else {
        let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string();
        (true, parent)
    };

    TreeTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "tree".to_string(),
        open_patches,
        open_issues,
        ref_name,
        path_display: path,
        show_parent,
        parent_path,
        entries,
    }
    .into_response()
}

pub async fn tree_root(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new())
}

pub async fn tree(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (ref_name, path) = split_ref_path(&rest);
    build_tree_response(&state, repo_name, &repo, ref_name, path)
}

pub async fn blob(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);
    let (ref_name, file_path) = split_ref_path(&rest);

    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)),
    };

    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return not_found(&state, "Could not resolve ref to a commit."),
    };

    let tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return internal_error(&state, "Failed to get commit tree."),
    };

    let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) {
        Ok(e) => e,
        Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)),
    };

    let obj = match entry_obj.to_object(&repo) {
        Ok(o) => o,
        Err(_) => return internal_error(&state, "Failed to resolve file object."),
    };

    let blob = match obj.into_blob() {
        Ok(b) => b,
        Err(_) => return not_found(&state, "Path is not a file."),
    };

    let is_binary = blob.is_binary();
    let size = blob.size();
    let size_display = if size < 1024 {
        format!("{} B", size)
    } else if size < 1024 * 1024 {
        format!("{:.1} KiB", size as f64 / 1024.0)
    } else {
        format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0))
    };

    let content = if is_binary {
        String::new()
    } else {
        String::from_utf8_lossy(blob.content()).into_owned()
    };

    BlobTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "tree".to_string(),
        open_patches,
        open_issues,
        ref_name,
        file_path,
        content,
        is_binary,
        size_display,
    }
    .into_response()
}

// ── Task 10: Diff page ────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct DiffLine {
    pub kind: String,
    pub text: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "diff.html")]
pub struct DiffTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub short_id: String,
    pub summary: String,
    pub body: String,
    pub author: String,
    pub date: String,
    pub diff_lines: Vec<DiffLine>,
}

fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> {
    let commit = match repo.find_commit(oid) {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };

    let new_tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return Vec::new(),
    };

    let old_tree = commit
        .parent(0)
        .ok()
        .and_then(|p| p.tree().ok());

    let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) {
        Ok(d) => d,
        Err(_) => return Vec::new(),
    };

    let mut lines: Vec<DiffLine> = Vec::new();

    let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
        let text = String::from_utf8_lossy(line.content()).into_owned();
        let kind = match line.origin() {
            '+' => "add",
            '-' => "del",
            '@' => "hunk",
            'F' => "file",
            _ => "ctx",
        }
        .to_string();
        lines.push(DiffLine { kind, text });
        true
    });

    lines
}

pub async fn diff(
    Path((repo_name, oid_str)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let oid = match git2::Oid::from_str(&oid_str) {
        Ok(o) => o,
        Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)),
    };

    let commit = match repo.find_commit(oid) {
        Ok(c) => c,
        Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)),
    };

    let id = oid.to_string();
    let short_id = id[..8.min(id.len())].to_string();
    let summary = commit.summary().unwrap_or("").to_string();
    let author = commit.author().name().unwrap_or("").to_string();
    let secs = commit.time().seconds();
    let date = Utc
        .timestamp_opt(secs, 0)
        .single()
        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
        .unwrap_or_default();

    // Body = message minus the first line (summary)
    let body = commit
        .message()
        .map(|m| {
            let lines: Vec<&str> = m.splitn(2, '\n').collect();
            if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() }
        })
        .unwrap_or_default();

    let diff_lines = compute_diff_lines(&repo, oid);

    DiffTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "commits".to_string(),
        open_patches,
        open_issues,
        short_id,
        summary,
        body,
        author,
        date,
        diff_lines,
    }
    .into_response()
}

// ── Task 11: Patches list and detail ─────────────────────────────────────────

#[derive(Debug)]
pub struct PatchListItem {
    pub id: String,
    pub short_id: String,
    pub status: String,
    pub title: String,
    pub author: String,
    pub branch: String,
    pub updated: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "patches.html")]
pub struct PatchesTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub patches: Vec<PatchListItem>,
}

#[derive(Debug)]
pub struct RevisionView {
    pub number: u32,
    pub commit: String,
    pub timestamp: String,
    pub body: Option<String>,
}

#[derive(Debug)]
pub struct ReviewView {
    pub author: String,
    pub verdict: String,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

#[derive(Debug)]
pub struct InlineCommentView {
    pub author: String,
    pub file: String,
    pub line: u32,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

#[derive(Debug)]
pub struct CommentView {
    pub author: String,
    pub body: String,
    pub timestamp: String,
}

#[derive(Debug)]
pub struct PatchDetailView {
    pub id: String,
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub branch: String,
    pub base_ref: String,
    pub revisions: Vec<RevisionView>,
    pub reviews: Vec<ReviewView>,
    pub inline_comments: Vec<InlineCommentView>,
    pub comments: Vec<CommentView>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "patch_detail.html")]
pub struct PatchDetailTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub patch: PatchDetailView,
}

pub async fn patches(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default();

    let patches = all_patches
        .into_iter()
        .map(|p| {
            let id = p.id.clone();
            let short_id = id[..8.min(id.len())].to_string();
            PatchListItem {
                short_id,
                id,
                status: p.status.as_str().to_string(),
                title: p.title,
                author: p.author.name,
                branch: p.branch,
                updated: p.last_updated,
            }
        })
        .collect();

    PatchesTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "patches".to_string(),
        open_patches,
        open_issues,
        patches,
    }
    .into_response()
}

pub async fn patch_detail(
    Path((repo_name, patch_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)),
    };

    let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) {
        Ok(s) => s,
        Err(_) => return internal_error(&state, "Failed to load patch state."),
    };

    let patch = PatchDetailView {
        id: ps.id,
        title: ps.title,
        body: ps.body,
        status: ps.status.as_str().to_string(),
        author: ps.author.name,
        branch: ps.branch,
        base_ref: ps.base_ref,
        revisions: ps.revisions.into_iter().map(|r| RevisionView {
            number: r.number,
            commit: r.commit,
            timestamp: r.timestamp,
            body: r.body,
        }).collect(),
        reviews: ps.reviews.into_iter().map(|r| ReviewView {
            author: r.author.name,
            verdict: r.verdict.as_str().to_string(),
            body: r.body,
            timestamp: r.timestamp,
            revision: r.revision,
        }).collect(),
        inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView {
            author: ic.author.name,
            file: ic.file,
            line: ic.line,
            body: ic.body,
            timestamp: ic.timestamp,
            revision: ic.revision,
        }).collect(),
        comments: ps.comments.into_iter().map(|c| CommentView {
            author: c.author.name,
            body: c.body,
            timestamp: c.timestamp,
        }).collect(),
    };

    PatchDetailTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "patches".to_string(),
        open_patches,
        open_issues,
        patch,
    }
    .into_response()
}

// ── Task 12: Issues list and detail ──────────────────────────────────────────

#[derive(Debug)]
pub struct IssueListItem {
    pub id: String,
    pub short_id: String,
    pub status: String,
    pub title: String,
    pub author: String,
    pub labels: String,
    pub updated: String,
}

#[derive(Debug)]
pub struct IssueDetailView {
    pub id: String,
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub labels: String,
    pub assignees: String,
    pub close_reason: Option<String>,
    pub comments: Vec<CommentView>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "issues.html")]
pub struct IssuesTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub issues: Vec<IssueListItem>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "issue_detail.html")]
pub struct IssueDetailTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub issue: IssueDetailView,
}

pub async fn issues(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default();

    let issues = all_issues
        .into_iter()
        .map(|i| {
            let id = i.id.clone();
            let short_id = id[..8.min(id.len())].to_string();
            IssueListItem {
                short_id,
                id,
                status: i.status.as_str().to_string(),
                title: i.title,
                author: i.author.name,
                labels: i.labels.join(", "),
                updated: i.last_updated,
            }
        })
        .collect();

    IssuesTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "issues".to_string(),
        open_patches,
        open_issues,
        issues,
    }
    .into_response()
}

pub async fn issue_detail(
    Path((repo_name, issue_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state, format!("Repository '{}' not found.", repo_name)),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return internal_error(&state, "Failed to open repository."),
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)),
    };

    let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) {
        Ok(s) => s,
        Err(_) => return internal_error(&state, "Failed to load issue state."),
    };

    let issue = IssueDetailView {
        id: is.id,
        title: is.title,
        body: is.body,
        status: is.status.as_str().to_string(),
        author: is.author.name,
        labels: is.labels.join(", "),
        assignees: is.assignees.join(", "),
        close_reason: is.close_reason,
        comments: is.comments.into_iter().map(|c| CommentView {
            author: c.author.name,
            body: c.body,
            timestamp: c.timestamp,
        }).collect(),
    };

    IssueDetailTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "issues".to_string(),
        open_patches,
        open_issues,
        issue,
    }
    .into_response()
}
diff --git a/src/server/http/repo/commits.rs b/src/server/http/repo/commits.rs
new file mode 100644
index 0000000..5b6a68b
--- /dev/null
+++ b/src/server/http/repo/commits.rs
@@ -0,0 +1,40 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits};

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "commits.html")]
pub struct CommitsTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub commits: Vec<OverviewCommit>,
}

pub async fn commits(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let commits = recent_commits(&repo, 200);
    let (open_patches, open_issues) = collab_counts(&repo);

    CommitsTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "commits".to_string(),
        open_patches,
        open_issues,
        commits,
    }
    .into_response()
}
diff --git a/src/server/http/repo/diff.rs b/src/server/http/repo/diff.rs
new file mode 100644
index 0000000..e1aad67
--- /dev/null
+++ b/src/server/http/repo/diff.rs
@@ -0,0 +1,127 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use chrono::{TimeZone, Utc};

use super::{AppState, collab_counts, not_found, open_repo};

#[derive(Debug)]
pub struct DiffLine {
    pub kind: String,
    pub text: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "diff.html")]
pub struct DiffTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub short_id: String,
    pub summary: String,
    pub body: String,
    pub author: String,
    pub date: String,
    pub diff_lines: Vec<DiffLine>,
}

fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> {
    let commit = match repo.find_commit(oid) {
        Ok(c) => c,
        Err(_) => return Vec::new(),
    };

    let new_tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return Vec::new(),
    };

    let old_tree = commit
        .parent(0)
        .ok()
        .and_then(|p| p.tree().ok());

    let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) {
        Ok(d) => d,
        Err(_) => return Vec::new(),
    };

    let mut lines: Vec<DiffLine> = Vec::new();

    let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
        let text = String::from_utf8_lossy(line.content()).into_owned();
        let kind = match line.origin() {
            '+' => "add",
            '-' => "del",
            '@' => "hunk",
            'F' => "file",
            _ => "ctx",
        }
        .to_string();
        lines.push(DiffLine { kind, text });
        true
    });

    lines
}

pub async fn diff(
    Path((repo_name, oid_str)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let oid = match git2::Oid::from_str(&oid_str) {
        Ok(o) => o,
        Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)),
    };

    let commit = match repo.find_commit(oid) {
        Ok(c) => c,
        Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)),
    };

    let id = oid.to_string();
    let short_id = id[..8.min(id.len())].to_string();
    let summary = commit.summary().unwrap_or("").to_string();
    let author = commit.author().name().unwrap_or("").to_string();
    let secs = commit.time().seconds();
    let date = Utc
        .timestamp_opt(secs, 0)
        .single()
        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
        .unwrap_or_default();

    let body = commit
        .message()
        .map(|m| {
            let lines: Vec<&str> = m.splitn(2, '\n').collect();
            if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() }
        })
        .unwrap_or_default();

    let diff_lines = compute_diff_lines(&repo, oid);

    DiffTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "commits".to_string(),
        open_patches,
        open_issues,
        short_id,
        summary,
        body,
        author,
        date,
        diff_lines,
    }
    .into_response()
}
diff --git a/src/server/http/repo/issues.rs b/src/server/http/repo/issues.rs
new file mode 100644
index 0000000..c01727f
--- /dev/null
+++ b/src/server/http/repo/issues.rs
@@ -0,0 +1,139 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo};

#[derive(Debug)]
pub struct IssueListItem {
    pub id: String,
    pub short_id: String,
    pub status: String,
    pub title: String,
    pub author: String,
    pub labels: String,
    pub updated: String,
}

#[derive(Debug)]
pub struct IssueDetailView {
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub labels: String,
    pub assignees: String,
    pub close_reason: Option<String>,
    pub comments: Vec<CommentView>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "issues.html")]
pub struct IssuesTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub issues: Vec<IssueListItem>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "issue_detail.html")]
pub struct IssueDetailTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub issue: IssueDetailView,
}

pub async fn issues(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default();

    let issues = all_issues
        .into_iter()
        .map(|i| {
            let id = i.id.clone();
            let short_id = id[..8.min(id.len())].to_string();
            IssueListItem {
                short_id,
                id,
                status: i.status.as_str().to_string(),
                title: i.title,
                author: i.author.name,
                labels: i.labels.join(", "),
                updated: i.last_updated,
            }
        })
        .collect();

    IssuesTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "issues".to_string(),
        open_patches,
        open_issues,
        issues,
    }
    .into_response()
}

pub async fn issue_detail(
    Path((repo_name, issue_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)),
    };

    let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) {
        Ok(s) => s,
        Err(_) => return internal_error(&state, "Failed to load issue state."),
    };

    let issue = IssueDetailView {
        title: is.title,
        body: is.body,
        status: is.status.as_str().to_string(),
        author: is.author.name,
        labels: is.labels.join(", "),
        assignees: is.assignees.join(", "),
        close_reason: is.close_reason,
        comments: is.comments.into_iter().map(|c| CommentView {
            author: c.author.name,
            body: c.body,
            timestamp: c.timestamp,
        }).collect(),
    };

    IssueDetailTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "issues".to_string(),
        open_patches,
        open_issues,
        issue,
    }
    .into_response()
}
diff --git a/src/server/http/repo/mod.rs b/src/server/http/repo/mod.rs
new file mode 100644
index 0000000..b0d0b4b
--- /dev/null
+++ b/src/server/http/repo/mod.rs
@@ -0,0 +1,130 @@
mod overview;
mod commits;
mod tree;
mod diff;
mod patches;
mod issues;

pub use overview::overview;
pub use commits::commits;
pub use tree::{tree_root, tree, blob};
pub use diff::diff;
pub use patches::{patches, patch_detail};
pub use issues::{issues, issue_detail};

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use chrono::{TimeZone, Utc};

use super::AppState;

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "error.html")]
pub struct ErrorTemplate {
    pub site_title: String,
    pub status_code: u16,
    pub message: String,
}

fn not_found(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 404,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::NOT_FOUND;
    response
}

fn internal_error(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 500,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
    response
}

/// Count open patches and issues for sidebar badges.
/// Note: this does a full state read on each call. For repos with many collab
/// objects this could be slow — consider caching with a short TTL if needed.
fn collab_counts(repo: &git2::Repository) -> (usize, usize) {
    let open_patches = git_collab::state::list_patches(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .count();

    let open_issues = git_collab::state::list_issues(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .count();

    (open_patches, open_issues)
}

#[derive(Debug)]
pub struct OverviewCommit {
    pub id: String,
    pub short_id: String,
    pub summary: String,
    pub author: String,
    pub date: String,
}

fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> {
    let mut revwalk = match repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return Vec::new(),
    };

    if revwalk.push_head().is_err() {
        return Vec::new();
    }

    revwalk
        .take(limit)
        .filter_map(|oid| {
            let oid = oid.ok()?;
            let commit = repo.find_commit(oid).ok()?;
            let id = oid.to_string();
            let short_id = id[..8.min(id.len())].to_string();
            let summary = commit.summary().unwrap_or("").to_string();
            let author = commit.author().name().unwrap_or("").to_string();
            let secs = commit.time().seconds();
            let date = Utc
                .timestamp_opt(secs, 0)
                .single()
                .map(|dt| dt.format("%Y-%m-%d").to_string())
                .unwrap_or_default();
            Some(OverviewCommit { id, short_id, summary, author, date })
        })
        .collect()
}

#[derive(Debug)]
pub struct CommentView {
    pub author: String,
    pub body: String,
    pub timestamp: String,
}

/// Open a repo by name, returning the entry and git2::Repository or an error Response.
#[allow(clippy::result_large_err)]
fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> {
    let entry = match crate::repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return Err(not_found(state, format!("Repository '{}' not found.", repo_name))),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return Err(internal_error(state, "Failed to open repository.")),
    };

    Ok((entry, repo))
}
diff --git a/src/server/http/repo/overview.rs b/src/server/http/repo/overview.rs
new file mode 100644
index 0000000..f774a9d
--- /dev/null
+++ b/src/server/http/repo/overview.rs
@@ -0,0 +1,80 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits};

#[derive(Debug)]
pub struct OverviewPatch {
    pub id: String,
    pub title: String,
    pub author: String,
}

#[derive(Debug)]
pub struct OverviewIssue {
    pub id: String,
    pub title: String,
    pub author: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "repo_overview.html")]
pub struct OverviewTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub commits: Vec<OverviewCommit>,
    pub patches: Vec<OverviewPatch>,
    pub issues: Vec<OverviewIssue>,
}

pub async fn overview(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let commits = recent_commits(&repo, 10);
    let (open_patches, open_issues) = collab_counts(&repo);

    let patches = git_collab::state::list_patches(&repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .map(|p| OverviewPatch {
            id: p.id,
            title: p.title,
            author: p.author.name,
        })
        .collect();

    let issues = git_collab::state::list_issues(&repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .map(|i| OverviewIssue {
            id: i.id,
            title: i.title,
            author: i.author.name,
        })
        .collect();

    OverviewTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "overview".to_string(),
        open_patches,
        open_issues,
        commits,
        patches,
        issues,
    }
    .into_response()
}
diff --git a/src/server/http/repo/patches.rs b/src/server/http/repo/patches.rs
new file mode 100644
index 0000000..544d059
--- /dev/null
+++ b/src/server/http/repo/patches.rs
@@ -0,0 +1,188 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo};

#[derive(Debug)]
pub struct PatchListItem {
    pub id: String,
    pub short_id: String,
    pub status: String,
    pub title: String,
    pub author: String,
    pub branch: String,
    pub updated: String,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "patches.html")]
pub struct PatchesTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub patches: Vec<PatchListItem>,
}

#[derive(Debug)]
pub struct RevisionView {
    pub number: u32,
    pub commit: String,
    pub timestamp: String,
    pub body: Option<String>,
}

#[derive(Debug)]
pub struct ReviewView {
    pub author: String,
    pub verdict: String,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

#[derive(Debug)]
pub struct InlineCommentView {
    pub author: String,
    pub file: String,
    pub line: u32,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

#[derive(Debug)]
pub struct PatchDetailView {
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub branch: String,
    pub base_ref: String,
    pub revisions: Vec<RevisionView>,
    pub reviews: Vec<ReviewView>,
    pub inline_comments: Vec<InlineCommentView>,
    pub comments: Vec<CommentView>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "patch_detail.html")]
pub struct PatchDetailTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub patch: PatchDetailView,
}

pub async fn patches(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default();

    let patches = all_patches
        .into_iter()
        .map(|p| {
            let id = p.id.clone();
            let short_id = id[..8.min(id.len())].to_string();
            PatchListItem {
                short_id,
                id,
                status: p.status.as_str().to_string(),
                title: p.title,
                author: p.author.name,
                branch: p.branch,
                updated: p.last_updated,
            }
        })
        .collect();

    PatchesTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "patches".to_string(),
        open_patches,
        open_issues,
        patches,
    }
    .into_response()
}

pub async fn patch_detail(
    Path((repo_name, patch_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);

    let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)),
    };

    let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) {
        Ok(s) => s,
        Err(_) => return internal_error(&state, "Failed to load patch state."),
    };

    let patch = PatchDetailView {
        title: ps.title,
        body: ps.body,
        status: ps.status.as_str().to_string(),
        author: ps.author.name,
        branch: ps.branch,
        base_ref: ps.base_ref,
        revisions: ps.revisions.into_iter().map(|r| RevisionView {
            number: r.number,
            commit: r.commit,
            timestamp: r.timestamp,
            body: r.body,
        }).collect(),
        reviews: ps.reviews.into_iter().map(|r| ReviewView {
            author: r.author.name,
            verdict: r.verdict.as_str().to_string(),
            body: r.body,
            timestamp: r.timestamp,
            revision: r.revision,
        }).collect(),
        inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView {
            author: ic.author.name,
            file: ic.file,
            line: ic.line,
            body: ic.body,
            timestamp: ic.timestamp,
            revision: ic.revision,
        }).collect(),
        comments: ps.comments.into_iter().map(|c| CommentView {
            author: c.author.name,
            body: c.body,
            timestamp: c.timestamp,
        }).collect(),
    };

    PatchDetailTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "patches".to_string(),
        open_patches,
        open_issues,
        patch,
    }
    .into_response()
}
diff --git a/src/server/http/repo/tree.rs b/src/server/http/repo/tree.rs
new file mode 100644
index 0000000..a2554f4
--- /dev/null
+++ b/src/server/http/repo/tree.rs
@@ -0,0 +1,238 @@
use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{AppState, collab_counts, internal_error, not_found, open_repo};

#[derive(Debug)]
pub struct TreeEntry {
    pub name: String,
    pub full_path: String,
    pub is_dir: bool,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "tree.html")]
pub struct TreeTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub ref_name: String,
    pub path_display: String,
    pub show_parent: bool,
    pub parent_path: String,
    pub entries: Vec<TreeEntry>,
}

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "blob.html")]
pub struct BlobTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub ref_name: String,
    pub file_path: String,
    pub content: String,
    pub is_binary: bool,
    pub size_display: String,
}

/// Split "ref/path/to/file" — first segment is ref, rest joined is path.
/// Returns ("HEAD", "") for empty input.
fn split_ref_path(input: &str) -> (String, String) {
    if input.is_empty() {
        return ("HEAD".to_string(), String::new());
    }
    let parts: Vec<&str> = input.splitn(2, '/').collect();
    let ref_name = parts[0].to_string();
    let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() };
    (ref_name, path)
}

fn build_tree_response(
    state: &AppState,
    repo_name: String,
    repo: &git2::Repository,
    ref_name: String,
    path: String,
) -> Response {
    let (open_patches, open_issues) = collab_counts(repo);

    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)),
    };

    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return not_found(state, "Could not resolve ref to a commit."),
    };

    let root_tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return internal_error(state, "Failed to get commit tree."),
    };

    let tree = if path.is_empty() {
        root_tree
    } else {
        let entry = match root_tree.get_path(std::path::Path::new(&path)) {
            Ok(e) => e,
            Err(_) => return not_found(state, format!("Path '{}' not found.", path)),
        };
        let obj = match entry.to_object(repo) {
            Ok(o) => o,
            Err(_) => return internal_error(state, "Failed to resolve path object."),
        };
        match obj.into_tree() {
            Ok(t) => t,
            Err(_) => return not_found(state, "Path is not a directory."),
        }
    };

    let mut entries: Vec<TreeEntry> = tree
        .iter()
        .map(|e| {
            let name = e.name().unwrap_or("").to_string();
            let full_path = if path.is_empty() {
                name.clone()
            } else {
                format!("{}/{}", path, name)
            };
            let is_dir = e.kind() == Some(git2::ObjectType::Tree);
            TreeEntry { name, full_path, is_dir }
        })
        .collect();

    entries.sort_by(|a, b| {
        match (a.is_dir, b.is_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.cmp(&b.name),
        }
    });

    let (show_parent, parent_path) = if path.is_empty() {
        (false, String::new())
    } else {
        let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string();
        (true, parent)
    };

    TreeTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "tree".to_string(),
        open_patches,
        open_issues,
        ref_name,
        path_display: path,
        show_parent,
        parent_path,
        entries,
    }
    .into_response()
}

pub async fn tree_root(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new())
}

pub async fn tree(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (ref_name, path) = split_ref_path(&rest);
    build_tree_response(&state, repo_name, &repo, ref_name, path)
}

pub async fn blob(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);
    let (ref_name, file_path) = split_ref_path(&rest);

    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)),
    };

    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return not_found(&state, "Could not resolve ref to a commit."),
    };

    let tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return internal_error(&state, "Failed to get commit tree."),
    };

    let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) {
        Ok(e) => e,
        Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)),
    };

    let obj = match entry_obj.to_object(&repo) {
        Ok(o) => o,
        Err(_) => return internal_error(&state, "Failed to resolve file object."),
    };

    let blob = match obj.into_blob() {
        Ok(b) => b,
        Err(_) => return not_found(&state, "Path is not a file."),
    };

    let is_binary = blob.is_binary();
    let size = blob.size();
    let size_display = if size < 1024 {
        format!("{} B", size)
    } else if size < 1024 * 1024 {
        format!("{:.1} KiB", size as f64 / 1024.0)
    } else {
        format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0))
    };

    let content = if is_binary {
        String::new()
    } else {
        String::from_utf8_lossy(blob.content()).into_owned()
    };

    BlobTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "tree".to_string(),
        open_patches,
        open_issues,
        ref_name,
        file_path,
        content,
        is_binary,
        size_display,
    }
    .into_response()
}
diff --git a/src/server/http/templates/base.html b/src/server/http/templates/base.html
index 4d27da3..6718f03 100644
--- a/src/server/http/templates/base.html
+++ b/src/server/http/templates/base.html
@@ -4,156 +4,6 @@
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{% block title %}{{ site_title }}{% endblock %}</title>
  <style>
    :root {
      --bg: #0d1117;
      --bg-secondary: #161b22;
      --bg-tertiary: #21262d;
      --border: #30363d;
      --text: #e6edf3;
      --text-muted: #8b949e;
      --text-link: #58a6ff;
      --green: #3fb950;
      --red: #f85149;
      --yellow: #d29922;
    }

    *, *::before, *::after { box-sizing: border-box; }

    body {
      margin: 0;
      background: var(--bg);
      color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
      font-size: 14px;
      line-height: 1.5;
    }

    a { color: var(--text-link); text-decoration: none; }
    a:hover { text-decoration: underline; }

    /* Header */
    .header {
      background: var(--bg-secondary);
      border-bottom: 1px solid var(--border);
      padding: 0 24px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      height: 48px;
    }
    .header a { color: var(--text); font-weight: 600; }

    /* Layout */
    .layout {
      display: flex;
      min-height: calc(100vh - 48px - 36px);
    }

    /* Sidebar */
    .sidebar {
      width: 180px;
      background: var(--bg-secondary);
      border-right: 1px solid var(--border);
      padding: 16px 0;
      flex-shrink: 0;
    }
    .sidebar a {
      display: block;
      padding: 6px 16px;
      color: var(--text);
    }
    .sidebar a:hover { background: var(--bg-tertiary); text-decoration: none; }
    .sidebar a.active {
      background: var(--bg-tertiary);
      border-left: 2px solid var(--text-link);
      color: var(--text-link);
      padding-left: 14px;
    }
    .sidebar .divider {
      height: 1px;
      background: var(--border);
      margin: 8px 16px;
    }
    .sidebar .badge {
      display: inline-block;
      background: var(--bg-tertiary);
      border: 1px solid var(--border);
      border-radius: 20px;
      padding: 0 6px;
      font-size: 11px;
      color: var(--text-muted);
      float: right;
    }

    /* Content */
    .content {
      flex: 1;
      padding: 24px;
      overflow: auto;
    }

    /* Footer */
    .footer {
      background: var(--bg-secondary);
      border-top: 1px solid var(--border);
      padding: 0 24px;
      height: 36px;
      display: flex;
      align-items: center;
      color: var(--text-muted);
      font-size: 12px;
    }

    /* Status badges */
    .status-open  { color: var(--green); }
    .status-closed { color: var(--red); }
    .status-merged { color: var(--yellow); }

    /* Diff */
    .diff-add  { background: rgba(63,185,80,0.1); color: var(--green); }
    .diff-del  { background: rgba(248,81,73,0.1); color: var(--red); }
    .diff-hunk { color: var(--text-muted); }
    .diff-file { font-weight: 600; border-top: 1px solid var(--border); padding: 8px 0 4px; }

    /* Mono */
    .mono, pre, code {
      font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
      font-size: 12px;
    }
    pre {
      background: var(--bg-tertiary);
      border: 1px solid var(--border);
      border-radius: 6px;
      padding: 12px 16px;
      overflow-x: auto;
    }
    code {
      background: var(--bg-tertiary);
      border-radius: 3px;
      padding: 0 4px;
    }
    pre code { background: none; padding: 0; }

    /* Tables */
    table {
      width: 100%;
      border-collapse: collapse;
    }
    th {
      text-align: left;
      color: var(--text-muted);
      font-weight: 400;
      border-bottom: 1px solid var(--border);
      padding: 8px 12px;
    }
    td {
      padding: 10px 12px;
      border-bottom: 1px solid var(--border);
    }
    tr:last-child td { border-bottom: none; }
    tr:hover td { background: var(--bg-secondary); }
  </style>
</head>
<body>
  <div class="header">
@@ -167,6 +17,5 @@
    </div>
  </div>

  <div class="footer">git-collab-server</div>
</body>
</html>
diff --git a/src/server/http/templates/repo_overview.html b/src/server/http/templates/repo_overview.html
index d2ac0d1..5afd167 100644
--- a/src/server/http/templates/repo_overview.html
+++ b/src/server/http/templates/repo_overview.html
@@ -74,7 +74,7 @@
    <tbody>
      {% for commit in commits %}
      <tr>
        <td class="mono"><a href="/{{ repo_name }}/commits/{{ commit.id }}">{{ commit.short_id }}</a></td>
        <td class="mono"><a href="/{{ repo_name }}/diff/{{ commit.id }}">{{ commit.short_id }}</a></td>
        <td>{{ commit.summary }}</td>
        <td style="color: var(--text-muted);">{{ commit.author }}</td>
        <td class="mono" style="color: var(--text-muted);">{{ commit.date }}</td>
diff --git a/src/server/repos.rs b/src/server/repos.rs
index 9b23aec..191809c 100644
--- a/src/server/repos.rs
+++ b/src/server/repos.rs
@@ -35,6 +35,8 @@ pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> {
}

/// Resolve a repo name to its on-disk entry.
/// Note: this performs a full directory scan on each call. Fine for a small
/// number of repos; consider caching with a short TTL if this becomes a bottleneck.
pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> {
    let entries = discover(repos_dir).ok()?;
    entries.into_iter().find(|e| e.name == name)
diff --git a/src/server/ssh/auth.rs b/src/server/ssh/auth.rs
index be26001..86bcc9a 100644
--- a/src/server/ssh/auth.rs
+++ b/src/server/ssh/auth.rs
@@ -1,6 +1,7 @@
use std::path::Path;

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AuthorizedKey {
    pub key_type: String,
    pub key_data: String,
diff --git a/src/server/ssh/mod.rs b/src/server/ssh/mod.rs
index 317ec7f..fd3e57a 100644
--- a/src/server/ssh/mod.rs
+++ b/src/server/ssh/mod.rs
@@ -26,6 +26,12 @@ pub fn load_or_generate_host_key(key_path: &Path) -> Result<KeyPair, Box<dyn std
            std::fs::create_dir_all(parent)?;
        }
        let file = std::fs::File::create(key_path)?;
        // Restrict host key file permissions to owner-only
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?;
        }
        russh_keys::encode_pkcs8_pem(&key, file)?;
        Ok(key)
    }
diff --git a/src/server/ssh/session.rs b/src/server/ssh/session.rs
index 2048a70..8cf95c1 100644
--- a/src/server/ssh/session.rs
+++ b/src/server/ssh/session.rs
@@ -7,6 +7,7 @@ use russh::{Channel, ChannelId, CryptoVec};
use russh_keys::key::PublicKey;
use russh_keys::PublicKeyBase64;
use tokio::process::Command;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};

use super::auth::{is_authorized, load_authorized_keys};
@@ -22,6 +23,8 @@ pub struct SshServerConfig {
pub struct SshHandler {
    config: Arc<SshServerConfig>,
    authenticated: bool,
    /// Sender for forwarding client data (stdin) to the spawned git subprocess.
    stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
}

impl SshHandler {
@@ -29,6 +32,7 @@ impl SshHandler {
        Self {
            config,
            authenticated: false,
            stdin_tx: None,
        }
    }
}
@@ -58,7 +62,7 @@ pub fn parse_git_command(data: &str) -> Option<(&str, &str)> {
}

/// Resolve a requested repo path to a safe absolute path under repos_dir.
/// Returns None if the path escapes repos_dir (e.g. via `..`).
/// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks).
pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> {
    let requested = requested.trim_start_matches('/');
    // Reject any path component that is ".."
@@ -73,11 +77,20 @@ pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> {
        }
    }
    let full = repos_dir.join(requested);
    // Double-check via canonicalize-like logic: the joined path must start with repos_dir
    // We use a simple prefix check on the cleaned path components
    // Prefix check on the joined path
    if !full.starts_with(repos_dir) {
        return None;
    }
    // If the path exists, canonicalize both to catch symlink escapes
    if full.exists() {
        if let (Ok(canon_repos), Ok(canon_full)) =
            (repos_dir.canonicalize(), full.canonicalize())
        {
            if !canon_full.starts_with(&canon_repos) {
                return None;
            }
        }
    }
    Some(full)
}

@@ -177,11 +190,15 @@ impl Handler for SshHandler {
            return Ok(());
        }

        // Create a channel for forwarding client stdin data to the git subprocess
        let (tx, rx) = mpsc::channel::<Vec<u8>>(64);
        self.stdin_tx = Some(tx);

        // Spawn the git subprocess
        let handle = session.handle();
        let git_cmd_owned = git_cmd;
        tokio::spawn(async move {
            if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path).await {
            if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path, rx).await {
                error!("Git subprocess error: {}", e);
            }
        });
@@ -191,17 +208,16 @@ impl Handler for SshHandler {

    async fn data(
        &mut self,
        channel: ChannelId,
        _channel: ChannelId,
        data: &[u8],
        session: &mut Session,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        // Forward client data to the channel (handled by russh internally for exec)
        // For git push, the client sends pack data via stdin — we need to forward it.
        // In the current design, the spawned task reads from the channel handle,
        // but russh's exec model means data arrives here. We just ignore it for now
        // since git-upload-pack (clone/fetch) doesn't need stdin from client in the
        // basic case, and git-receive-pack gets its data via the SSH channel.
        let _ = (channel, data, session);
        // Forward client data to the git subprocess's stdin
        if let Some(ref tx) = self.stdin_tx {
            if tx.send(data.to_vec()).await.is_err() {
                debug!("stdin channel closed, dropping data");
            }
        }
        Ok(())
    }
}
@@ -211,17 +227,31 @@ async fn run_git_command(
    channel: ChannelId,
    git_cmd: &str,
    repo_path: &Path,
    mut stdin_rx: mpsc::Receiver<Vec<u8>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    use tokio::io::AsyncReadExt;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    let mut child = Command::new(git_cmd)
        .arg(repo_path)
        .stdin(std::process::Stdio::null())
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()?;

    let mut child_stdin = child.stdin.take().expect("stdin piped");
    let mut stdout = child.stdout.take().expect("stdout piped");

    // Spawn a task to forward client data to the child's stdin
    tokio::spawn(async move {
        while let Some(data) = stdin_rx.recv().await {
            if child_stdin.write_all(&data).await.is_err() {
                break;
            }
        }
        // EOF: close stdin so the child knows we're done
        drop(child_stdin);
    });

    let mut buf = vec![0u8; 32768];

    loop {