a73x

809fe178

Add git-collab-server implementation plan

a73x   2026-03-30 18:30

17 tasks covering: project scaffolding, config, repo discovery,
SSH auth, askama templates, HTTP routes (repo list, overview,
commits, tree/blob, diff, patches, issues), git smart HTTP,
russh SSH server, and Makefile updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

diff --git a/docs/superpowers/plans/2026-03-30-git-collab-server.md b/docs/superpowers/plans/2026-03-30-git-collab-server.md
new file mode 100644
index 0000000..2a53234
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-30-git-collab-server.md
@@ -0,0 +1,3118 @@
# git-collab-server Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a minimal, self-contained git hosting server binary that serves a directory of repos with a read-only web UI, HTTPS clone, and SSH push/pull.

**Architecture:** Second `[[bin]]` in the existing workspace sharing the `git-collab` lib crate. axum for HTTP (web UI + git smart HTTP), russh for embedded SSH, askama for compiled HTML templates. Config via TOML file.

**Tech Stack:** Rust 2021, axum 0.8, tokio 1, askama 0.12, russh 0.44, russh-keys 0.44, toml 0.8, tracing 0.1, git2 0.19

---

### Task 1: Project scaffolding — Cargo.toml + minimal server binary

**Files:**
- Modify: `Cargo.toml`
- Create: `src/server/main.rs`

- [ ] **Step 1: Add `[[bin]]` entries and new dependencies to Cargo.toml**

Add explicit `[[bin]]` for the existing binary and the new server binary. Add server dependencies.

```toml
[[bin]]
name = "git-collab"
path = "src/main.rs"

[[bin]]
name = "git-collab-server"
path = "src/server/main.rs"
```

Add to `[dependencies]`:

```toml
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.12"
askama_axum = "0.12"
russh = "0.46"
russh-keys = "0.46"
toml_edit = "0.22"
tracing = "0.1"
tracing-subscriber = "0.3"
async-trait = "0.1"
```

Note: Use `toml_edit` instead of `toml` — lighter for just deserialization via serde. Use `russh` 0.46 (latest stable as of early 2026). Add `askama_axum` for the axum `IntoResponse` integration. Add `async-trait` since russh handler traits require it.

- [ ] **Step 2: Create minimal server main.rs**

```rust
// src/server/main.rs
fn main() {
    println!("git-collab-server: not yet implemented");
}
```

- [ ] **Step 3: Verify both binaries compile**

Run: `cargo build`
Expected: Both `git-collab` and `git-collab-server` compile without errors.

- [ ] **Step 4: Verify existing tests still pass**

Run: `cargo test`
Expected: All existing tests pass. No regressions from adding the second binary.

- [ ] **Step 5: Commit**

```bash
git add Cargo.toml Cargo.lock src/server/main.rs
git commit -m "scaffold git-collab-server binary with new dependencies"
```

---

### Task 2: Config module — parse collab-server.toml

**Files:**
- Create: `src/server/config.rs`
- Create: `src/server/mod.rs`
- Test: inline `#[cfg(test)]` module

- [ ] **Step 1: Write tests for config parsing**

Create `src/server/mod.rs`:

```rust
pub mod config;
```

Create `src/server/config.rs`:

```rust
use std::net::SocketAddr;
use std::path::PathBuf;

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
    pub repos_dir: PathBuf,
    #[serde(default = "default_http_bind")]
    pub http_bind: SocketAddr,
    #[serde(default = "default_ssh_bind")]
    pub ssh_bind: SocketAddr,
    pub authorized_keys: PathBuf,
    #[serde(default = "default_site_title")]
    pub site_title: String,
}

fn default_http_bind() -> SocketAddr {
    "0.0.0.0:8080".parse().unwrap()
}

fn default_ssh_bind() -> SocketAddr {
    "0.0.0.0:2222".parse().unwrap()
}

fn default_site_title() -> String {
    "git-collab".to_string()
}

impl ServerConfig {
    pub fn from_toml(content: &str) -> Result<Self, toml_edit::de::Error> {
        toml_edit::de::from_str(content)
    }

    pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
        let content = std::fs::read_to_string(path)?;
        Ok(Self::from_toml(&content)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_full_config() {
        let toml = r#"
repos_dir = "/srv/git"
http_bind = "127.0.0.1:3000"
ssh_bind = "127.0.0.1:2222"
authorized_keys = "/etc/git-collab-server/authorized_keys"
site_title = "my repos"
"#;
        let config = ServerConfig::from_toml(toml).unwrap();
        assert_eq!(config.repos_dir, PathBuf::from("/srv/git"));
        assert_eq!(config.http_bind, "127.0.0.1:3000".parse::<SocketAddr>().unwrap());
        assert_eq!(config.ssh_bind, "127.0.0.1:2222".parse::<SocketAddr>().unwrap());
        assert_eq!(config.authorized_keys, PathBuf::from("/etc/git-collab-server/authorized_keys"));
        assert_eq!(config.site_title, "my repos");
    }

    #[test]
    fn parse_minimal_config_uses_defaults() {
        let toml = r#"
repos_dir = "/srv/git"
authorized_keys = "/keys"
"#;
        let config = ServerConfig::from_toml(toml).unwrap();
        assert_eq!(config.repos_dir, PathBuf::from("/srv/git"));
        assert_eq!(config.http_bind, "0.0.0.0:8080".parse::<SocketAddr>().unwrap());
        assert_eq!(config.ssh_bind, "0.0.0.0:2222".parse::<SocketAddr>().unwrap());
        assert_eq!(config.site_title, "git-collab");
    }

    #[test]
    fn parse_missing_required_field_fails() {
        let toml = r#"
authorized_keys = "/keys"
"#;
        assert!(ServerConfig::from_toml(toml).is_err());
    }
}
```

- [ ] **Step 2: Run tests to verify they pass**

Run: `cargo test --lib server::config`
Expected: 3 tests pass.

- [ ] **Step 3: Commit**

```bash
git add src/server/mod.rs src/server/config.rs
git commit -m "add server config module with TOML parsing"
```

---

### Task 3: Repo discovery — scan repos_dir for git repos

**Files:**
- Create: `src/server/repos.rs`
- Modify: `src/server/mod.rs`

- [ ] **Step 1: Write tests for repo discovery**

Add `pub mod repos;` to `src/server/mod.rs`.

Create `src/server/repos.rs`:

```rust
use std::path::{Path, PathBuf};

/// A discovered git repository on disk.
#[derive(Debug, Clone)]
pub struct RepoEntry {
    /// Display name (directory name minus .git suffix).
    pub name: String,
    /// Absolute path to the repo (the .git dir for bare repos, or the workdir for non-bare).
    pub path: PathBuf,
    /// Whether this is a bare repository.
    pub bare: bool,
}

/// Scan a directory for git repositories.
/// A directory is a repo if it contains a HEAD file (bare) or a .git subdirectory (non-bare).
pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> {
    let mut entries = Vec::new();
    let read_dir = std::fs::read_dir(repos_dir)?;

    for entry in read_dir {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }

        let dir_name = entry.file_name().to_string_lossy().to_string();

        if path.join("HEAD").is_file() {
            // Bare repo (or looks like one)
            let name = dir_name.strip_suffix(".git").unwrap_or(&dir_name).to_string();
            entries.push(RepoEntry {
                name,
                path,
                bare: true,
            });
        } else if path.join(".git").is_dir() {
            // Non-bare repo
            entries.push(RepoEntry {
                name: dir_name,
                path,
                bare: false,
            });
        }
    }

    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}

/// Resolve a repo name from a URL path segment to its on-disk path.
/// Returns None if the name doesn't match any discovered repo.
pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> {
    let entries = discover(repos_dir).ok()?;
    entries.into_iter().find(|e| e.name == name)
}

/// Open a git2::Repository from a RepoEntry.
pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> {
    if entry.bare {
        git2::Repository::open_bare(&entry.path)
    } else {
        git2::Repository::open(&entry.path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use std::process::Command;

    fn init_bare(parent: &Path, name: &str) {
        Command::new("git")
            .args(["init", "--bare", name])
            .current_dir(parent)
            .output()
            .expect("git init --bare failed");
    }

    fn init_non_bare(parent: &Path, name: &str) {
        Command::new("git")
            .args(["init", name])
            .current_dir(parent)
            .output()
            .expect("git init failed");
    }

    #[test]
    fn discover_bare_repos() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "alpha.git");
        init_bare(tmp.path(), "beta.git");

        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 2);
        assert_eq!(repos[0].name, "alpha");
        assert_eq!(repos[1].name, "beta");
        assert!(repos[0].bare);
    }

    #[test]
    fn discover_non_bare_repos() {
        let tmp = TempDir::new().unwrap();
        init_non_bare(tmp.path(), "myproject");

        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 1);
        assert_eq!(repos[0].name, "myproject");
        assert!(!repos[0].bare);
    }

    #[test]
    fn discover_skips_non_repo_dirs() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join("not-a-repo")).unwrap();
        init_bare(tmp.path(), "real.git");

        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 1);
        assert_eq!(repos[0].name, "real");
    }

    #[test]
    fn resolve_finds_repo_by_name() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "myrepo.git");

        let entry = resolve(tmp.path(), "myrepo").unwrap();
        assert_eq!(entry.name, "myrepo");
    }

    #[test]
    fn resolve_returns_none_for_unknown() {
        let tmp = TempDir::new().unwrap();
        assert!(resolve(tmp.path(), "nope").is_none());
    }

    #[test]
    fn open_bare_repo() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "test.git");
        let entry = resolve(tmp.path(), "test").unwrap();
        let repo = open(&entry).unwrap();
        assert!(repo.is_bare());
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test --lib server::repos`
Expected: 6 tests pass.

- [ ] **Step 3: Commit**

```bash
git add src/server/mod.rs src/server/repos.rs
git commit -m "add repo discovery module for scanning repos directory"
```

---

### Task 4: SSH authorized_keys parser

**Files:**
- Create: `src/server/ssh/mod.rs`
- Create: `src/server/ssh/auth.rs`
- Modify: `src/server/mod.rs`

- [ ] **Step 1: Write the authorized_keys parser with tests**

Add `pub mod ssh;` to `src/server/mod.rs`.

Create `src/server/ssh/mod.rs`:

```rust
pub mod auth;
```

Create `src/server/ssh/auth.rs`:

```rust
use std::path::Path;

/// A parsed entry from the authorized_keys file.
#[derive(Debug, Clone)]
pub struct AuthorizedKey {
    /// The key type (e.g., "ssh-ed25519").
    pub key_type: String,
    /// Base64-encoded public key data.
    pub key_data: String,
    /// Optional comment (e.g., "user@host").
    pub comment: Option<String>,
}

/// Parse an authorized_keys file content into a list of keys.
/// Ignores blank lines and lines starting with #.
pub fn parse_authorized_keys(content: &str) -> Vec<AuthorizedKey> {
    content
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                return None;
            }
            let mut parts = line.splitn(3, ' ');
            let key_type = parts.next()?.to_string();
            let key_data = parts.next()?.to_string();
            let comment = parts.next().map(|s| s.to_string());
            Some(AuthorizedKey {
                key_type,
                key_data,
                comment,
            })
        })
        .collect()
}

/// Load authorized keys from a file on disk.
pub fn load_authorized_keys(path: &Path) -> Result<Vec<AuthorizedKey>, std::io::Error> {
    let content = std::fs::read_to_string(path)?;
    Ok(parse_authorized_keys(&content))
}

/// Check whether a given public key (type + base64 data) is in the authorized list.
pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool {
    keys.iter().any(|k| k.key_type == key_type && k.key_data == key_data)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_single_key() {
        let content = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
        assert_eq!(keys[0].key_type, "ssh-ed25519");
        assert_eq!(keys[0].key_data, "AAAAC3NzaC1lZDI1NTE5AAAAITest");
        assert_eq!(keys[0].comment.as_deref(), Some("user@host"));
    }

    #[test]
    fn parse_multiple_keys() {
        let content = "\
# alice
ssh-ed25519 AAAA1111 alice@example.com

# bob
ssh-ed25519 AAAA2222 bob@work
";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 2);
        assert_eq!(keys[0].key_data, "AAAA1111");
        assert_eq!(keys[1].key_data, "AAAA2222");
    }

    #[test]
    fn skip_comments_and_blanks() {
        let content = "\
# this is a comment

   # indented comment

ssh-ed25519 AAAAkey user
";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
    }

    #[test]
    fn key_without_comment() {
        let content = "ssh-ed25519 AAAAnocomment\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
        assert!(keys[0].comment.is_none());
    }

    #[test]
    fn is_authorized_matches() {
        let keys = parse_authorized_keys("ssh-ed25519 AAAA1111 alice\nssh-ed25519 AAAA2222 bob\n");
        assert!(is_authorized(&keys, "ssh-ed25519", "AAAA1111"));
        assert!(is_authorized(&keys, "ssh-ed25519", "AAAA2222"));
        assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999"));
        assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111"));
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test --lib server::ssh::auth`
Expected: 5 tests pass.

- [ ] **Step 3: Commit**

```bash
git add src/server/ssh/mod.rs src/server/ssh/auth.rs src/server/mod.rs
git commit -m "add SSH authorized_keys parser"
```

---

### Task 5: Askama base template + repo list template

**Files:**
- Create: `src/server/http/templates/base.html`
- Create: `src/server/http/templates/repo_list.html`
- Create: `src/server/http/templates/error.html`

- [ ] **Step 1: Create the base template with sidebar layout**

Create `src/server/http/templates/base.html`:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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;
  --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
  --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 14px; line-height: 1.5; }
a { color: var(--text-link); text-decoration: none; }
a:hover { text-decoration: underline; }

.header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; }
.header .site-title { font-size: 16px; font-weight: 600; color: var(--text); }
.header .repo-name { font-family: var(--font-mono); color: var(--text-muted); }

.layout { display: flex; min-height: calc(100vh - 45px - 32px); }

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

.content { flex: 1; padding: 16px 24px; min-width: 0; }

.footer { background: var(--bg-secondary); border-top: 1px solid var(--border); padding: 8px 16px; text-align: center; color: var(--text-muted); font-size: 12px; }

pre { font-family: var(--font-mono); font-size: 13px; overflow-x: auto; }
code { font-family: var(--font-mono); font-size: 13px; }
.mono { font-family: var(--font-mono); }

table { border-collapse: collapse; width: 100%; }
th, td { padding: 6px 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }

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

.diff-add { color: var(--green); }
.diff-del { color: var(--red); }
.diff-hunk { color: var(--text-link); }
.diff-file { color: var(--text); font-weight: bold; }
</style>
</head>
<body>
<div class="header">
  <a href="/" class="site-title">{{ site_title }}</a>
  {% block header_right %}{% endblock %}
</div>
{% block body %}{% endblock %}
<div class="footer">git-collab-server</div>
</body>
</html>
```

- [ ] **Step 2: Create the repo list template**

Create `src/server/http/templates/repo_list.html`:

```html
{% extends "base.html" %}

{% block body %}
<div class="content" style="max-width: 800px; margin: 0 auto; padding-top: 24px;">
  <h2 style="margin-bottom: 16px; font-size: 20px;">Repositories</h2>
  {% if repos.is_empty() %}
  <p style="color: var(--text-muted);">No repositories found.</p>
  {% else %}
  <table>
    <thead><tr><th>Name</th><th>Description</th><th>Last Commit</th></tr></thead>
    <tbody>
    {% for repo in repos %}
      <tr>
        <td><a href="/{{ repo.name }}" class="mono">{{ repo.name }}</a></td>
        <td style="color: var(--text-muted);">{{ repo.description }}</td>
        <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ repo.last_commit }}</td>
      </tr>
    {% endfor %}
    </tbody>
  </table>
  {% endif %}
</div>
{% endblock %}
```

- [ ] **Step 3: Create the error template**

Create `src/server/http/templates/error.html`:

```html
{% extends "base.html" %}

{% block title %}{{ status_code }} — {{ site_title }}{% endblock %}

{% block body %}
<div class="content" style="max-width: 600px; margin: 40px auto; text-align: center;">
  <h2 style="font-size: 48px; color: var(--text-muted);">{{ status_code }}</h2>
  <p style="color: var(--text-muted); margin-top: 8px;">{{ message }}</p>
</div>
{% endblock %}
```

- [ ] **Step 4: Commit**

```bash
git add src/server/http/templates/
git commit -m "add askama base template, repo list, and error templates"
```

---

### Task 6: HTTP skeleton — axum router + repo list handler

**Files:**
- Create: `src/server/http/mod.rs`
- Create: `src/server/http/repo_list.rs`
- Modify: `src/server/mod.rs`
- Modify: `src/server/main.rs`

- [ ] **Step 1: Create the shared app state and router**

Add `pub mod http;` to `src/server/mod.rs`.

Create `src/server/http/mod.rs`:

```rust
pub mod repo_list;

use std::path::PathBuf;
use std::sync::Arc;

use axum::Router;

/// Shared state available to all HTTP handlers.
#[derive(Debug, Clone)]
pub struct AppState {
    pub repos_dir: PathBuf,
    pub site_title: String,
}

pub fn router(state: AppState) -> Router {
    let shared = Arc::new(state);
    Router::new()
        .route("/", axum::routing::get(repo_list::handler))
        .with_state(shared)
}
```

- [ ] **Step 2: Create the repo list handler**

Create `src/server/http/repo_list.rs`:

```rust
use std::sync::Arc;

use askama::Template;
use axum::extract::State;
use axum::response::IntoResponse;

use super::AppState;
use crate::server::repos;

#[derive(Debug)]
pub struct RepoListItem {
    pub name: String,
    pub description: String,
    pub last_commit: String,
}

#[derive(Template)]
#[template(path = "repo_list.html")]
struct RepoListTemplate {
    site_title: String,
    repos: Vec<RepoListItem>,
}

fn repo_description(entry: &repos::RepoEntry) -> String {
    let desc_path = if entry.bare {
        entry.path.join("description")
    } else {
        entry.path.join(".git").join("description")
    };
    std::fs::read_to_string(desc_path)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.starts_with("Unnamed repository"))
        .unwrap_or_default()
}

fn last_commit_date(entry: &repos::RepoEntry) -> String {
    let repo = match repos::open(entry) {
        Ok(r) => r,
        Err(_) => return String::new(),
    };
    let head = match repo.head() {
        Ok(h) => h,
        Err(_) => return String::new(),
    };
    let commit = match head.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return String::new(),
    };
    let time = commit.time();
    let secs = time.seconds();
    let dt = chrono::DateTime::from_timestamp(secs, 0);
    dt.map(|d| d.format("%Y-%m-%d").to_string())
        .unwrap_or_default()
}

pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    let entries = repos::discover(&state.repos_dir).unwrap_or_default();
    let repos: Vec<RepoListItem> = entries
        .iter()
        .map(|e| RepoListItem {
            name: e.name.clone(),
            description: repo_description(e),
            last_commit: last_commit_date(e),
        })
        .collect();

    RepoListTemplate {
        site_title: state.site_title.clone(),
        repos,
    }
}
```

- [ ] **Step 3: Update main.rs to start the HTTP server**

Replace `src/server/main.rs`:

```rust
use std::path::PathBuf;

use clap::Parser;

mod config;
mod http;
mod repos;
mod ssh;

#[derive(Parser)]
#[command(name = "git-collab-server", about = "Minimal git hosting server")]
struct Args {
    /// Path to config file
    #[arg(short, long)]
    config: PathBuf,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let args = Args::parse();
    let config = match config::ServerConfig::from_file(&args.config) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: failed to load config {:?}: {}", args.config, e);
            std::process::exit(1);
        }
    };

    if !config.repos_dir.is_dir() {
        eprintln!("error: repos_dir {:?} is not a directory", config.repos_dir);
        std::process::exit(1);
    }

    let repo_count = repos::discover(&config.repos_dir)
        .map(|r| r.len())
        .unwrap_or(0);

    tracing::info!(
        "HTTP listening on {}, serving {} repos from {:?}",
        config.http_bind,
        repo_count,
        config.repos_dir
    );

    let app = http::router(http::AppState {
        repos_dir: config.repos_dir.clone(),
        site_title: config.site_title.clone(),
    });

    let listener = tokio::net::TcpListener::bind(config.http_bind)
        .await
        .expect("failed to bind HTTP listener");

    axum::serve(listener, app).await.expect("HTTP server error");
}
```

Note: This `main.rs` uses local `mod` declarations rather than referencing `git_collab::server` because it is the entry point for a separate binary. The modules live in `src/server/` adjacent to `main.rs`.

- [ ] **Step 4: Set askama config for template directory**

Create `askama.toml` in the project root (this is where askama looks for config):

```toml
[general]
dirs = ["src/server/http/templates"]
```

- [ ] **Step 5: Verify compilation**

Run: `cargo build`
Expected: Compiles. The template path resolution works via askama.toml.

- [ ] **Step 6: Smoke test — start server with a test config and curl the repo list**

Create a temporary config and test manually:

```bash
# Create test setup
mkdir -p /tmp/test-repos
git init --bare /tmp/test-repos/hello.git
echo '[repos]' > /tmp/test-server.toml
cat > /tmp/test-server.toml <<'EOF'
repos_dir = "/tmp/test-repos"
http_bind = "127.0.0.1:18080"
ssh_bind = "127.0.0.1:12222"
authorized_keys = "/dev/null"
EOF

# Start server in background, curl, then kill
cargo run --bin git-collab-server -- --config /tmp/test-server.toml &
SERVER_PID=$!
sleep 1
curl -s http://127.0.0.1:18080/ | grep -q "hello" && echo "PASS: repo list works" || echo "FAIL"
kill $SERVER_PID
```

Expected: The HTML response contains "hello".

- [ ] **Step 7: Commit**

```bash
git add src/server/ askama.toml
git commit -m "add HTTP skeleton with axum router and repo list page"
```

---

### Task 7: Repo sidebar template + repo overview page

**Files:**
- Create: `src/server/http/templates/repo_base.html`
- Create: `src/server/http/templates/repo_overview.html`
- Create: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create the repo base template (sidebar layout)**

Create `src/server/http/templates/repo_base.html`:

```html
{% extends "base.html" %}

{% block header_right %}
<span class="repo-name">{{ repo_name }}</span>
{% endblock %}

{% block body %}
<div class="layout">
  <nav class="sidebar">
    <a href="/{{ repo_name }}/commits"{% if active_section == "commits" %} class="active"{% endif %}>commits</a>
    <a href="/{{ repo_name }}/tree"{% if active_section == "tree" %} class="active"{% endif %}>tree</a>
    <div class="divider"></div>
    <a href="/{{ repo_name }}/patches"{% if active_section == "patches" %} class="active"{% endif %}>patches <span class="badge">{{ open_patches }}</span></a>
    <a href="/{{ repo_name }}/issues"{% if active_section == "issues" %} class="active"{% endif %}>issues <span class="badge">{{ open_issues }}</span></a>
  </nav>
  <div class="content">
    {% block content %}{% endblock %}
  </div>
</div>
{% endblock %}
```

- [ ] **Step 2: Create the repo overview template**

Create `src/server/http/templates/repo_overview.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 16px;">{{ repo_name }}</h2>

<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
  <div>
    <h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Open Patches</h3>
    {% if patches.is_empty() %}
    <p style="color: var(--text-muted); font-size: 13px;">None</p>
    {% else %}
    {% for p in patches %}
    <div style="padding: 4px 0; border-bottom: 1px solid var(--border);">
      <a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a>
      <span style="color: var(--text-muted); font-size: 12px;"> by {{ p.author }}</span>
    </div>
    {% endfor %}
    {% endif %}
  </div>

  <div>
    <h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Open Issues</h3>
    {% if issues.is_empty() %}
    <p style="color: var(--text-muted); font-size: 13px;">None</p>
    {% else %}
    {% for i in issues %}
    <div style="padding: 4px 0; border-bottom: 1px solid var(--border);">
      <a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a>
      <span style="color: var(--text-muted); font-size: 12px;"> by {{ i.author }}</span>
    </div>
    {% endfor %}
    {% endif %}
  </div>
</div>

<h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Recent Commits</h3>
{% for c in commits %}
<div style="padding: 4px 0; border-bottom: 1px solid var(--border); display: flex; gap: 12px;">
  <span class="mono" style="color: var(--text-muted); flex-shrink: 0;">{{ c.short_id }}</span>
  <span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
    <a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.summary }}</a>
  </span>
  <span style="color: var(--text-muted); font-size: 12px; flex-shrink: 0;">{{ c.author }}</span>
  <span class="mono" style="color: var(--text-muted); font-size: 12px; flex-shrink: 0;">{{ c.date }}</span>
</div>
{% endfor %}
{% endblock %}
```

- [ ] **Step 3: Create the repo handler module**

Create `src/server/http/repo.rs`:

```rust
use std::sync::Arc;

use askama::Template;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};

use super::AppState;
use crate::server::repos;

// --- Shared helpers ---

pub fn not_found(site_title: &str) -> Response {
    let t = ErrorTemplate {
        site_title: site_title.to_string(),
        status_code: 404,
        message: "Repository not found".to_string(),
    };
    (StatusCode::NOT_FOUND, t.into_response()).into_response()
}

pub fn internal_error(site_title: &str, msg: &str) -> Response {
    let t = ErrorTemplate {
        site_title: site_title.to_string(),
        status_code: 500,
        message: msg.to_string(),
    };
    (StatusCode::INTERNAL_SERVER_ERROR, t.into_response()).into_response()
}

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

// --- Overview structs ---

pub struct OverviewCommit {
    pub id: String,
    pub short_id: String,
    pub summary: String,
    pub author: String,
    pub date: String,
}

pub struct OverviewPatch {
    pub id: String,
    pub title: String,
    pub author: String,
}

pub struct OverviewIssue {
    pub id: String,
    pub title: String,
    pub author: String,
}

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

fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> {
    let head = match repo.head() {
        Ok(h) => h,
        Err(_) => return Vec::new(),
    };
    let oid = match head.target() {
        Some(o) => o,
        None => return Vec::new(),
    };
    let mut revwalk = match repo.revwalk() {
        Ok(r) => r,
        Err(_) => return Vec::new(),
    };
    let _ = revwalk.push(oid);
    revwalk.set_sorting(git2::Sort::TIME).ok();

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

pub async fn overview(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let commits = recent_commits(&repo, 20);

    // Load collab data
    let all_patches = git_collab::state::list_patches(&repo).unwrap_or_default();
    let open_patch_count = all_patches.iter().filter(|p| p.status == git_collab::state::PatchStatus::Open).count();
    let patches: Vec<OverviewPatch> = all_patches
        .iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .take(10)
        .map(|p| OverviewPatch {
            id: p.id[..8].to_string(),
            title: p.title.clone(),
            author: p.author.name.clone(),
        })
        .collect();

    let all_issues = git_collab::state::list_issues(&repo).unwrap_or_default();
    let open_issue_count = all_issues.iter().filter(|i| i.status == git_collab::state::IssueStatus::Open).count();
    let issues: Vec<OverviewIssue> = all_issues
        .iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .take(10)
        .map(|i| OverviewIssue {
            id: i.id[..8].to_string(),
            title: i.title.clone(),
            author: i.author.name.clone(),
        })
        .collect();

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

- [ ] **Step 4: Add the overview route to the router**

Update `src/server/http/mod.rs` — add `repo` module and route:

```rust
pub mod repo;
pub mod repo_list;

use std::path::PathBuf;
use std::sync::Arc;

use axum::Router;

#[derive(Debug, Clone)]
pub struct AppState {
    pub repos_dir: PathBuf,
    pub site_title: String,
}

pub fn router(state: AppState) -> Router {
    let shared = Arc::new(state);
    Router::new()
        .route("/", axum::routing::get(repo_list::handler))
        .route("/{repo_name}", axum::routing::get(repo::overview))
        .with_state(shared)
}
```

- [ ] **Step 5: Verify compilation and smoke test**

Run: `cargo build`
Expected: Compiles. Templates are validated at build time by askama.

- [ ] **Step 6: Commit**

```bash
git add src/server/http/
git commit -m "add repo overview page with sidebar navigation"
```

---

### Task 8: Commits page

**Files:**
- Create: `src/server/http/templates/commits.html`
- Modify: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create the commits template**

Create `src/server/http/templates/commits.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 16px;">Commits</h2>
<table>
  <thead><tr><th>Hash</th><th>Message</th><th>Author</th><th>Date</th></tr></thead>
  <tbody>
  {% for c in commits %}
  <tr>
    <td class="mono"><a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.short_id }}</a></td>
    <td>{{ c.summary }}</td>
    <td style="color: var(--text-muted);">{{ c.author }}</td>
    <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ c.date }}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endblock %}
```

- [ ] **Step 2: Add commits handler to repo.rs**

Append to `src/server/http/repo.rs`:

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

pub async fn commits(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let commits = recent_commits(&repo, 200);
    let open_patches = git_collab::state::list_patches(&repo).unwrap_or_default()
        .iter().filter(|p| p.status == git_collab::state::PatchStatus::Open).count();
    let open_issues = git_collab::state::list_issues(&repo).unwrap_or_default()
        .iter().filter(|i| i.status == git_collab::state::IssueStatus::Open).count();

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

- [ ] **Step 3: Add route**

Add to `src/server/http/mod.rs` router:

```rust
.route("/{repo_name}/commits", axum::routing::get(repo::commits))
```

- [ ] **Step 4: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 5: Commit**

```bash
git add src/server/http/
git commit -m "add commits page"
```

---

### Task 9: Tree and blob pages (file browser)

**Files:**
- Create: `src/server/http/templates/tree.html`
- Create: `src/server/http/templates/blob.html`
- Modify: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create tree template**

Create `src/server/http/templates/tree.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 4px;">{{ path_display }}</h2>
<p style="color: var(--text-muted); margin-bottom: 16px; font-size: 12px;">ref: {{ ref_name }}</p>
<table>
  <thead><tr><th>Name</th><th>Type</th></tr></thead>
  <tbody>
  {% if show_parent %}
  <tr>
    <td><a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ parent_path }}">..</a></td>
    <td style="color: var(--text-muted);">directory</td>
  </tr>
  {% endif %}
  {% for entry in entries %}
  <tr>
    <td>
      {% if entry.is_dir %}
      <a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}/</a>
      {% else %}
      <a href="/{{ repo_name }}/blob/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}</a>
      {% endif %}
    </td>
    <td style="color: var(--text-muted);">{% if entry.is_dir %}directory{% else %}file{% endif %}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endblock %}
```

- [ ] **Step 2: Create blob template**

Create `src/server/http/templates/blob.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 4px;">{{ file_path }}</h2>
<p style="color: var(--text-muted); margin-bottom: 16px; font-size: 12px;">
  ref: {{ ref_name }} &middot; {{ size_display }}
</p>
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; overflow-x: auto;">
{% if is_binary %}
<p style="color: var(--text-muted);">Binary file ({{ size_display }})</p>
{% else %}
<pre>{{ content }}</pre>
{% endif %}
</div>
{% endblock %}
```

- [ ] **Step 3: Add tree and blob handlers to repo.rs**

Append to `src/server/http/repo.rs`:

```rust
pub struct TreeEntry {
    pub name: String,
    pub full_path: String,
    pub is_dir: bool,
}

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

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

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

fn resolve_tree_at_path(
    repo: &git2::Repository,
    ref_name: &str,
    path: &str,
) -> Result<git2::Tree<'_>, git2::Error> {
    let obj = repo.revparse_single(ref_name)?;
    let commit = obj.peel_to_commit()?;
    let root_tree = commit.tree()?;

    if path.is_empty() {
        Ok(root_tree)
    } else {
        let entry = root_tree.get_path(std::path::Path::new(path))?;
        let obj = entry.to_object(repo)?;
        obj.peel_to_tree().map_err(|_| git2::Error::from_str("not a directory"))
    }
}

pub async fn tree(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    // Parse ref/path — first segment is the ref, rest is the path
    let (ref_name, path) = split_ref_path(&rest);
    let (open_patches, open_issues) = collab_counts(&repo);

    let tree = match resolve_tree_at_path(&repo, &ref_name, &path) {
        Ok(t) => t,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    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 path_display = if path.is_empty() { "/".to_string() } else { format!("/{}", path) };
    let show_parent = !path.is_empty();
    let parent_path = path.rsplit_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();

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

pub async fn tree_root(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    tree(Path((repo_name, "HEAD".to_string())), State(state)).await
}

pub async fn blob(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let (ref_name, file_path) = split_ref_path(&rest);
    if file_path.is_empty() {
        return not_found(&state.site_title);
    }

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

    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };
    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };
    let root_tree = match commit.tree() {
        Ok(t) => t,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };
    let tree_entry = match root_tree.get_path(std::path::Path::new(&file_path)) {
        Ok(e) => e,
        Err(_) => return not_found(&state.site_title),
    };
    let blob_obj = match tree_entry.to_object(&repo) {
        Ok(o) => o,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };
    let blob = match blob_obj.as_blob() {
        Some(b) => b,
        None => return not_found(&state.site_title),
    };

    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} KB", size as f64 / 1024.0)
    } else {
        format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
    };

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

    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()
}

/// Split "ref/path/to/file" into (ref, path).
/// Tries to find a valid ref by progressively consuming segments.
/// Falls back to treating the first segment as the ref.
fn split_ref_path(input: &str) -> (String, String) {
    let input = input.trim_start_matches('/');
    if input.is_empty() {
        return ("HEAD".to_string(), String::new());
    }

    // Simple split: first segment is ref, rest is path
    if let Some((first, rest)) = input.split_once('/') {
        (first.to_string(), rest.to_string())
    } else {
        (input.to_string(), String::new())
    }
}
```

- [ ] **Step 4: Add routes**

Add to `src/server/http/mod.rs` router:

```rust
.route("/{repo_name}/tree", axum::routing::get(repo::tree_root))
.route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree))
.route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob))
```

- [ ] **Step 5: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 6: Commit**

```bash
git add src/server/http/
git commit -m "add tree and blob file browser pages"
```

---

### Task 10: Diff page (single commit diff)

**Files:**
- Create: `src/server/http/templates/diff.html`
- Modify: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create diff template**

Create `src/server/http/templates/diff.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 4px; font-family: var(--font-mono);">{{ short_id }}</h2>
<p style="margin-bottom: 4px;">{{ summary }}</p>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;">
  {{ author }} &middot; {{ date }}
</p>
{% if !body.is_empty() %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;">
<pre>{{ body }}</pre>
</div>
{% endif %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; overflow-x: auto;">
<pre>{% for line in diff_lines %}{% if line.kind == "add" %}<span class="diff-add">{{ line.text }}</span>
{% else if line.kind == "del" %}<span class="diff-del">{{ line.text }}</span>
{% else if line.kind == "hunk" %}<span class="diff-hunk">{{ line.text }}</span>
{% else if line.kind == "file" %}<span class="diff-file">{{ line.text }}</span>
{% else %}{{ line.text }}
{% endif %}{% endfor %}</pre>
</div>
{% endblock %}
```

- [ ] **Step 2: Add diff handler to repo.rs**

Append to `src/server/http/repo.rs`:

```rust
pub struct DiffLine {
    pub kind: String,
    pub text: String,
}

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

fn compute_diff_lines(repo: &git2::Repository, commit: &git2::Commit) -> Vec<DiffLine> {
    let tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return Vec::new(),
    };
    let parent_tree = commit
        .parent(0)
        .ok()
        .and_then(|p| p.tree().ok());

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

    let mut lines = Vec::new();
    let _ = diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
        let text = String::from_utf8_lossy(line.content()).to_string();
        let text = text.trim_end_matches('\n').to_string();
        let kind = match line.origin() {
            '+' => "add",
            '-' => "del",
            'H' => "hunk",
            'F' => "file",
            _ => "ctx",
        };
        // For file header lines, include the delta path
        if line.origin() == 'F' || line.origin() == 'H' {
            lines.push(DiffLine { kind: kind.to_string(), text });
        } else {
            let prefix = if line.origin() == '+' || line.origin() == '-' {
                line.origin().to_string()
            } else {
                " ".to_string()
            };
            lines.push(DiffLine { kind: kind.to_string(), text: format!("{}{}", prefix, text) });
        }
        true
    });
    lines
}

pub async fn diff(
    Path((repo_name, oid_str)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let oid = match git2::Oid::from_str(&oid_str) {
        Ok(o) => o,
        Err(_) => return not_found(&state.site_title),
    };
    let commit = match repo.find_commit(oid) {
        Ok(c) => c,
        Err(_) => return not_found(&state.site_title),
    };

    let (open_patches, open_issues) = collab_counts(&repo);
    let id = oid.to_string();
    let short_id = id[..8].to_string();
    let summary = commit.summary().unwrap_or("").to_string();
    let body = commit.body().unwrap_or("").to_string();
    let author = commit.author().name().unwrap_or("").to_string();
    let time = commit.time();
    let dt = chrono::DateTime::from_timestamp(time.seconds(), 0);
    let date = dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
    let diff_lines = compute_diff_lines(&repo, &commit);

    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()
}
```

- [ ] **Step 3: Add route**

Add to router:

```rust
.route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff))
```

- [ ] **Step 4: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 5: Commit**

```bash
git add src/server/http/
git commit -m "add commit diff page"
```

---

### Task 11: Patches list and detail pages

**Files:**
- Create: `src/server/http/templates/patches.html`
- Create: `src/server/http/templates/patch_detail.html`
- Modify: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create patches list template**

Create `src/server/http/templates/patches.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 16px;">Patches</h2>
{% if patches.is_empty() %}
<p style="color: var(--text-muted);">No patches found.</p>
{% else %}
<table>
  <thead><tr><th>ID</th><th>Status</th><th>Title</th><th>Author</th><th>Branch</th><th>Updated</th></tr></thead>
  <tbody>
  {% for p in patches %}
  <tr>
    <td class="mono"><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.short_id }}</a></td>
    <td><span class="status-{{ p.status }}">{{ p.status }}</span></td>
    <td>{{ p.title }}</td>
    <td style="color: var(--text-muted);">{{ p.author }}</td>
    <td class="mono" style="color: var(--text-muted);">{{ p.branch }}</td>
    <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ p.updated }}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endif %}
{% endblock %}
```

- [ ] **Step 2: Create patch detail template**

Create `src/server/http/templates/patch_detail.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 4px;">{{ patch.title }}</h2>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;">
  <span class="status-{{ patch.status }}">{{ patch.status }}</span>
  &middot; {{ patch.author }} &middot; {{ patch.branch }} &rarr; {{ patch.base_ref }}
  &middot; created {{ patch.created_at }}
</p>

{% if !patch.body.is_empty() %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;">
<pre>{{ patch.body }}</pre>
</div>
{% endif %}

{% if !revisions.is_empty() %}
<h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Revisions</h3>
<table>
  <thead><tr><th>#</th><th>Commit</th><th>Date</th><th>Note</th></tr></thead>
  <tbody>
  {% for r in revisions %}
  <tr>
    <td>r{{ r.number }}</td>
    <td class="mono">{{ r.short_commit }}</td>
    <td class="mono" style="color: var(--text-muted);">{{ r.timestamp }}</td>
    <td style="color: var(--text-muted);">{{ r.body }}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endif %}

{% if !reviews.is_empty() %}
<h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Reviews</h3>
{% for r in reviews %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;">
  <p style="font-size: 13px;">
    <strong>{{ r.author }}</strong>
    <span class="status-{% if r.verdict == "approve" %}open{% else %}closed{% endif %}">{{ r.verdict }}</span>
    {% if let Some(rev) = r.revision %}<span style="color: var(--text-muted);"> (r{{ rev }})</span>{% endif %}
    <span style="color: var(--text-muted);"> &middot; {{ r.timestamp }}</span>
  </p>
  {% if !r.body.is_empty() %}
  <pre style="margin-top: 8px;">{{ r.body }}</pre>
  {% endif %}
</div>
{% endfor %}
{% endif %}

{% if !inline_comments.is_empty() %}
<h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Inline Comments</h3>
{% for c in inline_comments %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;">
  <p style="font-size: 12px; color: var(--text-muted);">
    <strong style="color: var(--text);">{{ c.author }}</strong> on
    <span class="mono">{{ c.file }}:{{ c.line }}</span>
    {% if let Some(rev) = c.revision %} (r{{ rev }}){% endif %}
    &middot; {{ c.timestamp }}
  </p>
  <pre style="margin-top: 4px;">{{ c.body }}</pre>
</div>
{% endfor %}
{% endif %}

{% if !comments.is_empty() %}
<h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Comments</h3>
{% for c in comments %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;">
  <p style="font-size: 12px; color: var(--text-muted);">
    <strong style="color: var(--text);">{{ c.author }}</strong> &middot; {{ c.timestamp }}
  </p>
  <pre style="margin-top: 4px;">{{ c.body }}</pre>
</div>
{% endfor %}
{% endif %}
{% endblock %}
```

- [ ] **Step 3: Add patches handlers to repo.rs**

Append to `src/server/http/repo.rs`:

```rust
// --- Patches ---

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(Template)]
#[template(path = "patches.html")]
struct PatchesTemplate {
    site_title: String,
    repo_name: String,
    active_section: String,
    open_patches: usize,
    open_issues: usize,
    patches: Vec<PatchListItem>,
}

pub async fn patches(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let all = git_collab::state::list_patches(&repo).unwrap_or_default();
    let (open_patches, open_issues) = collab_counts(&repo);

    let patches: Vec<PatchListItem> = all
        .iter()
        .map(|p| PatchListItem {
            id: p.id.clone(),
            short_id: p.id[..8].to_string(),
            status: p.status.as_str().to_string(),
            title: p.title.clone(),
            author: p.author.name.clone(),
            branch: p.branch.clone(),
            updated: p.last_updated.chars().take(10).collect(),
        })
        .collect();

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

// --- Patch detail ---

pub struct PatchDetailView {
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub branch: String,
    pub base_ref: String,
    pub created_at: String,
}

pub struct RevisionView {
    pub number: u32,
    pub short_commit: String,
    pub timestamp: String,
    pub body: String,
}

pub struct ReviewView {
    pub author: String,
    pub verdict: String,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

pub struct InlineCommentView {
    pub author: String,
    pub file: String,
    pub line: u32,
    pub body: String,
    pub timestamp: String,
    pub revision: Option<u32>,
}

pub struct CommentView {
    pub author: String,
    pub body: String,
    pub timestamp: String,
}

#[derive(Template)]
#[template(path = "patch_detail.html")]
struct PatchDetailTemplate {
    site_title: String,
    repo_name: String,
    active_section: String,
    open_patches: usize,
    open_issues: usize,
    patch: PatchDetailView,
    revisions: Vec<RevisionView>,
    reviews: Vec<ReviewView>,
    inline_comments: Vec<InlineCommentView>,
    comments: Vec<CommentView>,
}

pub async fn patch_detail(
    Path((repo_name, patch_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state.site_title),
    };
    let p = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) {
        Ok(p) => p,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

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

    let patch = PatchDetailView {
        title: p.title.clone(),
        body: p.body.clone(),
        status: p.status.as_str().to_string(),
        author: p.author.name.clone(),
        branch: p.branch.clone(),
        base_ref: p.base_ref.clone(),
        created_at: p.created_at.chars().take(10).collect(),
    };

    let revisions: Vec<RevisionView> = p.revisions.iter().map(|r| RevisionView {
        number: r.number,
        short_commit: r.commit.chars().take(8).collect(),
        timestamp: r.timestamp.chars().take(10).collect(),
        body: r.body.clone().unwrap_or_default(),
    }).collect();

    let reviews: Vec<ReviewView> = p.reviews.iter().map(|r| ReviewView {
        author: r.author.name.clone(),
        verdict: r.verdict.as_str().to_string(),
        body: r.body.clone(),
        timestamp: r.timestamp.chars().take(10).collect(),
        revision: r.revision,
    }).collect();

    let inline_comments: Vec<InlineCommentView> = p.inline_comments.iter().map(|c| InlineCommentView {
        author: c.author.name.clone(),
        file: c.file.clone(),
        line: c.line,
        body: c.body.clone(),
        timestamp: c.timestamp.chars().take(10).collect(),
        revision: c.revision,
    }).collect();

    let comments: Vec<CommentView> = p.comments.iter().map(|c| CommentView {
        author: c.author.name.clone(),
        body: c.body.clone(),
        timestamp: c.timestamp.chars().take(10).collect(),
    }).collect();

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

- [ ] **Step 4: Add routes**

Add to router:

```rust
.route("/{repo_name}/patches", axum::routing::get(repo::patches))
.route("/{repo_name}/patches/{id}", axum::routing::get(repo::patch_detail))
```

- [ ] **Step 5: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 6: Commit**

```bash
git add src/server/http/
git commit -m "add patches list and detail pages"
```

---

### Task 12: Issues list and detail pages

**Files:**
- Create: `src/server/http/templates/issues.html`
- Create: `src/server/http/templates/issue_detail.html`
- Modify: `src/server/http/repo.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create issues list template**

Create `src/server/http/templates/issues.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 16px;">Issues</h2>
{% if issues.is_empty() %}
<p style="color: var(--text-muted);">No issues found.</p>
{% else %}
<table>
  <thead><tr><th>ID</th><th>Status</th><th>Title</th><th>Author</th><th>Labels</th><th>Updated</th></tr></thead>
  <tbody>
  {% for i in issues %}
  <tr>
    <td class="mono"><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.short_id }}</a></td>
    <td><span class="status-{{ i.status }}">{{ i.status }}</span></td>
    <td>{{ i.title }}</td>
    <td style="color: var(--text-muted);">{{ i.author }}</td>
    <td style="color: var(--text-muted);">{{ i.labels }}</td>
    <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ i.updated }}</td>
  </tr>
  {% endfor %}
  </tbody>
</table>
{% endif %}
{% endblock %}
```

- [ ] **Step 2: Create issue detail template**

Create `src/server/http/templates/issue_detail.html`:

```html
{% extends "repo_base.html" %}

{% block content %}
<h2 style="margin-bottom: 4px;">{{ issue.title }}</h2>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;">
  <span class="status-{{ issue.status }}">{{ issue.status }}</span>
  &middot; {{ issue.author }} &middot; created {{ issue.created_at }}
  {% if !issue.labels.is_empty() %} &middot; [{{ issue.labels }}]{% endif %}
  {% if !issue.assignees.is_empty() %} &middot; assigned to {{ issue.assignees }}{% endif %}
</p>

{% if !issue.body.is_empty() %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;">
<pre>{{ issue.body }}</pre>
</div>
{% endif %}

{% if let Some(reason) = issue.close_reason.as_ref() %}
<p style="color: var(--text-muted); margin-bottom: 16px;">Closed: {{ reason }}</p>
{% endif %}

{% if !comments.is_empty() %}
<h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Comments</h3>
{% for c in comments %}
<div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;">
  <p style="font-size: 12px; color: var(--text-muted);">
    <strong style="color: var(--text);">{{ c.author }}</strong> &middot; {{ c.timestamp }}
  </p>
  <pre style="margin-top: 4px;">{{ c.body }}</pre>
</div>
{% endfor %}
{% endif %}
{% endblock %}
```

- [ ] **Step 3: Add issues handlers to repo.rs**

Append to `src/server/http/repo.rs`:

```rust
// --- Issues ---

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(Template)]
#[template(path = "issues.html")]
struct IssuesTemplate {
    site_title: String,
    repo_name: String,
    active_section: String,
    open_patches: usize,
    open_issues: usize,
    issues: Vec<IssueListItem>,
}

pub async fn issues(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let all = git_collab::state::list_issues(&repo).unwrap_or_default();
    let (open_patches, open_issues) = collab_counts(&repo);

    let issues: Vec<IssueListItem> = all
        .iter()
        .map(|i| IssueListItem {
            id: i.id.clone(),
            short_id: i.id[..8].to_string(),
            status: i.status.as_str().to_string(),
            title: i.title.clone(),
            author: i.author.name.clone(),
            labels: i.labels.join(", "),
            updated: i.last_updated.chars().take(10).collect(),
        })
        .collect();

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

// --- Issue detail ---

pub struct IssueDetailView {
    pub title: String,
    pub body: String,
    pub status: String,
    pub author: String,
    pub labels: String,
    pub assignees: String,
    pub created_at: String,
    pub close_reason: Option<String>,
}

#[derive(Template)]
#[template(path = "issue_detail.html")]
struct IssueDetailTemplate {
    site_title: String,
    repo_name: String,
    active_section: String,
    open_patches: usize,
    open_issues: usize,
    issue: IssueDetailView,
    comments: Vec<CommentView>,
}

pub async fn issue_detail(
    Path((repo_name, issue_id)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let entry = match repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return not_found(&state.site_title),
    };
    let repo = match repos::open(&entry) {
        Ok(r) => r,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

    let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) {
        Ok(r) => r,
        Err(_) => return not_found(&state.site_title),
    };
    let i = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) {
        Ok(i) => i,
        Err(e) => return internal_error(&state.site_title, &e.to_string()),
    };

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

    let issue = IssueDetailView {
        title: i.title.clone(),
        body: i.body.clone(),
        status: i.status.as_str().to_string(),
        author: i.author.name.clone(),
        labels: i.labels.join(", "),
        assignees: i.assignees.join(", "),
        created_at: i.created_at.chars().take(10).collect(),
        close_reason: i.close_reason.clone(),
    };

    let comments: Vec<CommentView> = i.comments.iter().map(|c| CommentView {
        author: c.author.name.clone(),
        body: c.body.clone(),
        timestamp: c.timestamp.chars().take(10).collect(),
    }).collect();

    IssueDetailTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "issues".to_string(),
        open_patches,
        open_issues,
        issue,
        comments,
    }
    .into_response()
}
```

- [ ] **Step 4: Add routes**

Add to router:

```rust
.route("/{repo_name}/issues", axum::routing::get(repo::issues))
.route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail))
```

- [ ] **Step 5: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 6: Commit**

```bash
git add src/server/http/
git commit -m "add issues list and detail pages"
```

---

### Task 13: Git smart HTTP (HTTPS clone)

**Files:**
- Create: `src/server/http/git_http.rs`
- Modify: `src/server/http/mod.rs`

- [ ] **Step 1: Create git smart HTTP handler**

Create `src/server/http/git_http.rs`:

```rust
use std::sync::Arc;

use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use tokio::process::Command;

use super::AppState;
use crate::server::repos;

#[derive(serde::Deserialize)]
pub struct InfoRefsQuery {
    pub service: Option<String>,
}

/// GET /:repo.git/info/refs?service=git-upload-pack
pub async fn info_refs(
    Path(repo_dot_git): Path<String>,
    Query(query): Query<InfoRefsQuery>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let service = match query.service.as_deref() {
        Some("git-upload-pack") => "git-upload-pack",
        _ => {
            return (StatusCode::FORBIDDEN, "only git-upload-pack is supported")
                .into_response();
        }
    };

    let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git);
    let entry = match repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return StatusCode::NOT_FOUND.into_response(),
    };

    let repo_path = if entry.bare {
        entry.path.clone()
    } else {
        entry.path.join(".git")
    };

    let output = match Command::new("git")
        .args(["upload-pack", "--stateless-rpc", "--advertise-refs"])
        .arg(&repo_path)
        .output()
        .await
    {
        Ok(o) => o,
        Err(e) => {
            tracing::error!("git upload-pack --advertise-refs failed: {}", e);
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
        }
    };

    if !output.status.success() {
        tracing::error!(
            "git upload-pack --advertise-refs exited with {}",
            output.status
        );
        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
    }

    // Smart HTTP protocol: prepend service announcement
    let mut body = Vec::new();
    let announcement = format!("# service={}\n", service);
    let pkt_line = format!("{:04x}{}", announcement.len() + 4, announcement);
    body.extend_from_slice(pkt_line.as_bytes());
    body.extend_from_slice(b"0000"); // flush-pkt
    body.extend_from_slice(&output.stdout);

    let content_type = format!("application/x-{}-advertisement", service);
    let mut headers = HeaderMap::new();
    headers.insert("Content-Type", content_type.parse().unwrap());
    headers.insert("Cache-Control", "no-cache".parse().unwrap());

    (headers, body).into_response()
}

/// POST /:repo.git/git-upload-pack
pub async fn upload_pack(
    Path(repo_dot_git): Path<String>,
    State(state): State<Arc<AppState>>,
    body: axum::body::Bytes,
) -> Response {
    let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git);
    let entry = match repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return StatusCode::NOT_FOUND.into_response(),
    };

    let repo_path = if entry.bare {
        entry.path.clone()
    } else {
        entry.path.join(".git")
    };

    let mut child = match Command::new("git")
        .args(["upload-pack", "--stateless-rpc"])
        .arg(&repo_path)
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            tracing::error!("failed to spawn git upload-pack: {}", e);
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
        }
    };

    // Write request body to stdin
    if let Some(mut stdin) = child.stdin.take() {
        use tokio::io::AsyncWriteExt;
        let _ = stdin.write_all(&body).await;
        drop(stdin);
    }

    let output = match child.wait_with_output().await {
        Ok(o) => o,
        Err(e) => {
            tracing::error!("git upload-pack failed: {}", e);
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
        }
    };

    let mut headers = HeaderMap::new();
    headers.insert(
        "Content-Type",
        "application/x-git-upload-pack-result".parse().unwrap(),
    );
    headers.insert("Cache-Control", "no-cache".parse().unwrap());

    (headers, output.stdout).into_response()
}
```

- [ ] **Step 2: Add routes**

Add `pub mod git_http;` to `src/server/http/mod.rs` and routes:

```rust
.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))
```

- [ ] **Step 3: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 4: Integration test — clone via HTTP**

```bash
# Setup
mkdir -p /tmp/test-repos
rm -rf /tmp/test-repos/hello.git
git init --bare /tmp/test-repos/hello.git
# Add a commit so there's something to clone
cd /tmp && rm -rf /tmp/hello-work && git clone /tmp/test-repos/hello.git /tmp/hello-work
cd /tmp/hello-work && echo "hello" > README.md && git add . && git commit -m "init" && git push origin main

cat > /tmp/test-server.toml <<'EOF'
repos_dir = "/tmp/test-repos"
http_bind = "127.0.0.1:18080"
ssh_bind = "127.0.0.1:12222"
authorized_keys = "/dev/null"
EOF

cargo run --bin git-collab-server -- --config /tmp/test-server.toml &
SERVER_PID=$!
sleep 1

# Test clone
rm -rf /tmp/hello-clone
git clone http://127.0.0.1:18080/hello.git /tmp/hello-clone && echo "PASS: HTTP clone works" || echo "FAIL"

kill $SERVER_PID
```

Expected: Clone succeeds.

- [ ] **Step 5: Commit**

```bash
git add src/server/http/git_http.rs src/server/http/mod.rs
git commit -m "add git smart HTTP for read-only HTTPS clone"
```

---

### Task 14: SSH server — russh with key auth and git exec

**Files:**
- Create: `src/server/ssh/session.rs`
- Modify: `src/server/ssh/mod.rs`

- [ ] **Step 1: Create the SSH session handler**

Create `src/server/ssh/session.rs`:

```rust
use std::path::{Path, PathBuf};
use std::sync::Arc;

use async_trait::async_trait;
use russh::server::{Auth, Msg, Session};
use russh::{Channel, ChannelId, CryptoVec};
use tokio::sync::Mutex;

use super::auth;

/// Shared server configuration for SSH.
#[derive(Debug, Clone)]
pub struct SshServerConfig {
    pub repos_dir: PathBuf,
    pub authorized_keys_path: PathBuf,
}

/// Per-connection handler.
pub struct SshHandler {
    config: Arc<SshServerConfig>,
    authenticated: bool,
}

impl SshHandler {
    pub fn new(config: Arc<SshServerConfig>) -> Self {
        Self {
            config,
            authenticated: false,
        }
    }
}

#[async_trait]
impl russh::server::Handler for SshHandler {
    type Error = anyhow::Error;

    async fn auth_publickey(
        &mut self,
        user: &str,
        public_key: &russh_keys::key::PublicKey,
    ) -> Result<Auth, Self::Error> {
        let key_data = public_key.public_key_base64();
        let key_type = public_key.name().to_string();

        let keys = auth::load_authorized_keys(&self.config.authorized_keys_path)
            .unwrap_or_default();

        if auth::is_authorized(&keys, &key_type, &key_data) {
            tracing::info!(user = user, key_type = key_type, "SSH auth accepted");
            self.authenticated = true;
            Ok(Auth::Accept)
        } else {
            tracing::warn!(user = user, key_type = key_type, "SSH auth rejected");
            Ok(Auth::Reject {
                proceed_with_methods: None,
            })
        }
    }

    async fn channel_open_session(
        &mut self,
        channel: Channel<Msg>,
        session: &mut Session,
    ) -> Result<bool, Self::Error> {
        Ok(self.authenticated)
    }

    async fn exec_request(
        &mut self,
        channel_id: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        let command = String::from_utf8_lossy(data).to_string();
        tracing::info!(command = command, "SSH exec request");

        // Parse the command — only allow git-upload-pack and git-receive-pack
        let (git_cmd, repo_path) = match parse_git_command(&command) {
            Some(parsed) => parsed,
            None => {
                tracing::warn!(command = command, "SSH command rejected");
                session.close(channel_id);
                return Ok(());
            }
        };

        // Validate repo path against repos_dir
        let resolved = match resolve_repo_path(&self.config.repos_dir, &repo_path) {
            Some(p) => p,
            None => {
                tracing::warn!(path = repo_path, "SSH repo path rejected (traversal or not found)");
                session.close(channel_id);
                return Ok(());
            }
        };

        // Spawn the git subprocess
        let child = tokio::process::Command::new(&git_cmd)
            .arg(&resolved)
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn();

        let mut child = match child {
            Ok(c) => c,
            Err(e) => {
                tracing::error!(error = %e, "failed to spawn {}", git_cmd);
                session.close(channel_id);
                return Ok(());
            }
        };

        // Relay stdin/stdout between SSH channel and subprocess
        let handle = session.handle();
        let stdin = child.stdin.take();
        let stdout = child.stdout.take();

        tokio::spawn(async move {
            use tokio::io::{AsyncReadExt, AsyncWriteExt};

            if let Some(mut stdout) = stdout {
                let mut buf = vec![0u8; 32768];
                loop {
                    match stdout.read(&mut buf).await {
                        Ok(0) => break,
                        Ok(n) => {
                            let _ = handle.data(channel_id, CryptoVec::from_slice(&buf[..n])).await;
                        }
                        Err(_) => break,
                    }
                }
            }

            let status = child.wait().await.map(|s| s.code().unwrap_or(1)).unwrap_or(1);
            let _ = handle.exit_status_request(channel_id, status as u32).await;
            let _ = handle.eof(channel_id).await;
            let _ = handle.close(channel_id).await;
        });

        Ok(())
    }

    async fn data(
        &mut self,
        channel_id: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        // Data from client → relay to subprocess stdin
        // For simplicity in v1, we handle this via the spawned task's stdin
        // This requires a more complex architecture with channels; for now
        // the subprocess gets stdin from the exec_request spawn
        Ok(())
    }
}

/// Parse a git command string like `git-upload-pack '/path/to/repo'`
/// Returns (command, repo_path) or None if not a valid git command.
fn parse_git_command(command: &str) -> Option<(String, String)> {
    let command = command.trim();
    let (cmd, rest) = command.split_once(' ')?;

    match cmd {
        "git-upload-pack" | "git-receive-pack" => {}
        _ => return None,
    }

    // Strip surrounding quotes from the path
    let path = rest.trim();
    let path = path
        .strip_prefix('\'')
        .and_then(|p| p.strip_suffix('\''))
        .or_else(|| path.strip_prefix('"').and_then(|p| p.strip_suffix('"')))
        .unwrap_or(path);

    Some((cmd.to_string(), path.to_string()))
}

/// Validate and resolve a repo path to a real path under repos_dir.
/// Prevents path traversal attacks.
fn resolve_repo_path(repos_dir: &Path, repo_path: &str) -> Option<PathBuf> {
    // Strip leading / — repo_path comes from the SSH command
    let repo_path = repo_path.trim_start_matches('/');
    let repo_name = repo_path.strip_suffix(".git").unwrap_or(repo_path);

    // Check for traversal
    if repo_name.contains("..") || repo_name.contains('\0') {
        return None;
    }

    let entry = crate::server::repos::resolve(repos_dir, repo_name)?;

    if entry.bare {
        Some(entry.path)
    } else {
        Some(entry.path.join(".git"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_upload_pack() {
        let (cmd, path) = parse_git_command("git-upload-pack '/srv/git/repo.git'").unwrap();
        assert_eq!(cmd, "git-upload-pack");
        assert_eq!(path, "/srv/git/repo.git");
    }

    #[test]
    fn parse_receive_pack() {
        let (cmd, path) = parse_git_command("git-receive-pack '/srv/git/repo.git'").unwrap();
        assert_eq!(cmd, "git-receive-pack");
        assert_eq!(path, "/srv/git/repo.git");
    }

    #[test]
    fn reject_unknown_command() {
        assert!(parse_git_command("rm -rf /").is_none());
        assert!(parse_git_command("ls /srv").is_none());
    }

    #[test]
    fn reject_traversal() {
        let tmp = tempfile::TempDir::new().unwrap();
        assert!(resolve_repo_path(tmp.path(), "../../../etc/passwd").is_none());
        assert!(resolve_repo_path(tmp.path(), "repo/../../../etc/passwd").is_none());
    }
}
```

- [ ] **Step 2: Run tests**

Run: `cargo test --lib server::ssh::session`
Expected: 4 tests pass.

- [ ] **Step 3: Add SSH server startup to ssh/mod.rs**

Update `src/server/ssh/mod.rs`:

```rust
pub mod auth;
pub mod session;

use std::path::PathBuf;
use std::sync::Arc;

use session::SshServerConfig;

/// Generate or load the server host key.
pub fn load_or_generate_host_key(
    key_path: &std::path::Path,
) -> Result<russh_keys::key::KeyPair, Box<dyn std::error::Error>> {
    if key_path.exists() {
        let key = russh_keys::load_secret_key(key_path, None)?;
        Ok(key)
    } else {
        let key = russh_keys::key::KeyPair::generate_ed25519().unwrap();
        if let Some(parent) = key_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        russh_keys::encode_pkcs8_pem(&key, key_path)?;
        Ok(key)
    }
}

/// Start the SSH server.
pub async fn serve(
    bind: std::net::SocketAddr,
    host_key: russh_keys::key::KeyPair,
    ssh_config: Arc<SshServerConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
    let config = russh::server::Config {
        keys: vec![host_key],
        ..Default::default()
    };
    let config = Arc::new(config);

    let server = Server { ssh_config };
    russh::server::run(config, bind, server).await?;
    Ok(())
}

struct Server {
    ssh_config: Arc<SshServerConfig>,
}

impl russh::server::Server for Server {
    type Handler = session::SshHandler;

    fn new_client(&mut self, _peer_addr: Option<std::net::SocketAddr>) -> Self::Handler {
        session::SshHandler::new(self.ssh_config.clone())
    }
}
```

- [ ] **Step 4: Verify compilation**

Run: `cargo build`
Expected: Compiles (russh API may need adjustments depending on exact version — fix any trait method signature mismatches).

- [ ] **Step 5: Commit**

```bash
git add src/server/ssh/
git commit -m "add SSH server with key auth and git exec handling"
```

---

### Task 15: Wire up main.rs — HTTP + SSH startup

**Files:**
- Modify: `src/server/main.rs`

- [ ] **Step 1: Update main.rs to start both HTTP and SSH**

Replace `src/server/main.rs`:

```rust
use std::path::PathBuf;
use std::sync::Arc;

use clap::Parser;

mod config;
mod http;
mod repos;
mod ssh;

#[derive(Parser)]
#[command(name = "git-collab-server", about = "Minimal git hosting server")]
struct Args {
    /// Path to config file
    #[arg(short, long)]
    config: PathBuf,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let args = Args::parse();
    let config = match config::ServerConfig::from_file(&args.config) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: failed to load config {:?}: {}", args.config, e);
            std::process::exit(1);
        }
    };

    if !config.repos_dir.is_dir() {
        eprintln!("error: repos_dir {:?} is not a directory", config.repos_dir);
        std::process::exit(1);
    }

    let repo_count = repos::discover(&config.repos_dir)
        .map(|r| r.len())
        .unwrap_or(0);

    // Load or generate SSH host key
    let key_dir = config.repos_dir.join(".server");
    let key_path = key_dir.join("host_key");
    let host_key = match ssh::load_or_generate_host_key(&key_path) {
        Ok(k) => k,
        Err(e) => {
            eprintln!("error: failed to load/generate SSH host key: {}", e);
            std::process::exit(1);
        }
    };

    let fingerprint = host_key.clone_public_key()
        .map(|pk| pk.fingerprint())
        .unwrap_or_else(|| "unknown".to_string());
    tracing::info!("SSH host key fingerprint: {}", fingerprint);

    tracing::info!(
        "HTTP listening on {}, SSH listening on {}, serving {} repos from {:?}",
        config.http_bind,
        config.ssh_bind,
        repo_count,
        config.repos_dir
    );

    let app = http::router(http::AppState {
        repos_dir: config.repos_dir.clone(),
        site_title: config.site_title.clone(),
    });

    let ssh_config = Arc::new(ssh::session::SshServerConfig {
        repos_dir: config.repos_dir.clone(),
        authorized_keys_path: config.authorized_keys.clone(),
    });

    let http_bind = config.http_bind;
    let ssh_bind = config.ssh_bind;

    let http_task = tokio::spawn(async move {
        let listener = tokio::net::TcpListener::bind(http_bind)
            .await
            .expect("failed to bind HTTP listener");
        axum::serve(listener, app).await.expect("HTTP server error");
    });

    let ssh_task = tokio::spawn(async move {
        if let Err(e) = ssh::serve(ssh_bind, host_key, ssh_config).await {
            tracing::error!("SSH server error: {}", e);
        }
    });

    tokio::select! {
        _ = http_task => { tracing::error!("HTTP server exited"); }
        _ = ssh_task => { tracing::error!("SSH server exited"); }
    }
}
```

- [ ] **Step 2: Verify compilation**

Run: `cargo build`
Expected: Compiles.

- [ ] **Step 3: Full integration smoke test**

```bash
mkdir -p /tmp/test-repos
rm -rf /tmp/test-repos/hello.git
git init --bare /tmp/test-repos/hello.git

cat > /tmp/test-server.toml <<'EOF'
repos_dir = "/tmp/test-repos"
http_bind = "127.0.0.1:18080"
ssh_bind = "127.0.0.1:12222"
authorized_keys = "/dev/null"
EOF

cargo run --bin git-collab-server -- --config /tmp/test-server.toml &
SERVER_PID=$!
sleep 2

# Test web UI
curl -s http://127.0.0.1:18080/ | grep -q "hello" && echo "PASS: repo list" || echo "FAIL: repo list"
curl -s http://127.0.0.1:18080/hello | grep -q "Commits" || echo "NOTE: overview may be empty (no commits)"

kill $SERVER_PID
```

Expected: Server starts, web UI responds.

- [ ] **Step 4: Commit**

```bash
git add src/server/main.rs
git commit -m "wire up HTTP + SSH concurrent startup in main"
```

---

### Task 16: Makefile updates

**Files:**
- Modify: `Makefile`

- [ ] **Step 1: Add server targets to Makefile**

Add to the Makefile:

```makefile
SERVER_BIN := git-collab-server

# Add to install target
install-server: PROFILE := release
install-server: build
	install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN)

# Dev helper
serve:
	$(CARGO) run --bin $(SERVER_BIN) -- $(ARGS)
```

- [ ] **Step 2: Update the existing install target to include the server**

Modify the `install` target to also install the server binary:

```makefile
install: PROFILE := release
install: build install-man
	install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN)
	install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN)
```

- [ ] **Step 3: Verify make targets**

Run: `make build`
Expected: Both binaries compile.

- [ ] **Step 4: Commit**

```bash
git add Makefile
git commit -m "add server build and install targets to Makefile"
```

---

### Task 17: Final verification — clippy + all tests

**Files:** None (verification only)

- [ ] **Step 1: Run clippy**

Run: `cargo clippy -- -D warnings`
Expected: No warnings. Fix any that appear.

- [ ] **Step 2: Run all tests**

Run: `cargo test`
Expected: All tests pass — existing tests + new config/repos/auth/session tests.

- [ ] **Step 3: Commit any fixes**

If clippy or tests required fixes, commit them:

```bash
git add -u
git commit -m "fix clippy warnings and test issues"
```