a73x

docs/superpowers/plans/2026-04-12-render-readme-on-overview.md

Ref:   Size: 30.0 KiB

# Render README on Repo Overview — 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:** Render the repository's README on `GET /{repo}` above the existing patches/issues/recent-commits sections, with markdown rendering, sanitization, and a hard size cap.

**Architecture:** A new pure module `src/server/http/repo/readme.rs` walks HEAD's root tree, finds a README by case-insensitive name (`README.md` → `README` → `README.txt`), renders markdown via `pulldown-cmark` + `ammonia` (with `<img>` URL schemes restricted to `http`/`https`), and returns `Option<RenderedReadme>`. The overview handler calls it and the template renders the safe HTML inside a bordered card.

**Tech Stack:** Rust 2021, axum 0.8, askama 0.15, git2 0.19, pulldown-cmark 0.12, ammonia 4. Tests use `tempfile` and the existing `tests/common::ServerHarness`.

**Spec:** `docs/superpowers/specs/2026-04-12-render-readme-on-overview-design.md`

---

## File Structure

| File | Action | Responsibility |
|---|---|---|
| `Cargo.toml` | Modify | Add `pulldown-cmark`, `ammonia` deps |
| `src/server/http/repo/readme.rs` | Create | All README lookup, decoding, rendering, sanitization. Pure (no Axum / state). Contains its own `#[cfg(test)] mod tests`. |
| `src/server/http/repo/mod.rs` | Modify | `mod readme;` + `pub use readme::RenderedReadme;` if needed |
| `src/server/http/repo/overview.rs` | Modify | Call `readme::load_readme(&repo)`, add `readme: Option<RenderedReadme>` field to `OverviewTemplate` |
| `src/server/http/templates/repo_overview.html` | Modify | Render the readme card above the patches/issues grid |
| `tests/server_behavior_test.rs` | Modify | One end-to-end integration test against the running router |

---

## Conventions for every task

- **TDD**: write the failing test, watch it fail, write the minimal code, watch it pass, commit.
- **Test execution**: run only the specific test under development with
  `cargo test --test <crate> <test_name> -- --nocapture` or
  `cargo test -p git-collab readme:: -- --nocapture` for unit tests inside `readme.rs`.
- **Commits**: small, conventional. Co-author trailer is added by the harness, no need to include manually unless the user asks.
- **Do not touch** the pre-existing modifications to `src/server/http/repo/issues.rs` and `src/server/http/repo/patches.rs`. They are unrelated to this work.
- **Working branch**: assume `main` (the user has been committing directly). If you want a worktree, create one before Task 1.

---

## Task 1: Add dependencies

**Files:**
- Modify: `Cargo.toml`

- [ ] **Step 1: Add pulldown-cmark and ammonia to `[dependencies]`**

In `Cargo.toml`, add these two lines to the `[dependencies]` block (alphabetical order is not enforced in the existing file; group near `askama` for tidiness):

```toml
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
ammonia = "4"
```

- [ ] **Step 2: Verify the workspace builds**

Run: `cargo build`
Expected: clean build, two new crates resolved. If `pulldown-cmark` rejects `default-features = false` because `html` is not a feature in 0.12, fall back to `pulldown-cmark = "0.12"` and re-run `cargo build`.

- [ ] **Step 3: Commit**

```bash
git add Cargo.toml Cargo.lock
git commit -m "deps: add pulldown-cmark and ammonia for README rendering"
```

---

## Task 2: Module skeleton + first failing test (no README → None)

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

- [ ] **Step 1: Create the module file with type and stub**

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

```rust
//! README lookup, decoding, and rendering for the repo overview page.
//!
//! Pure: no Axum / AppState dependencies, so this is unit-testable against
//! fixture repos built with `git2::Repository::init`.

use git2::Repository;

/// Already-safe HTML ready to be rendered with `|safe` in the template.
pub struct RenderedReadme {
    pub html: String,
}

/// Load and render the README at the root of HEAD's tree.
///
/// Returns `None` when no README is present, the blob is binary or invalid
/// UTF-8, or any unexpected git2 error occurs. The README must never break
/// the overview page — failures degrade silently to "no README".
pub fn load_readme(_repo: &Repository) -> Option<RenderedReadme> {
    todo!("Task 2 implements the no-README path")
}

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

    /// Build an empty git repo with a single commit containing the given
    /// (path, contents) blobs at the root tree. Returns the repo and the
    /// tempdir (kept alive by the caller).
    fn repo_with_files(files: &[(&str, &[u8])]) -> (Repository, TempDir) {
        let tmp = TempDir::new().unwrap();
        let repo = Repository::init(tmp.path()).unwrap();

        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
        let mut builder = repo.treebuilder(None).unwrap();
        for (name, contents) in files {
            let oid = repo.blob(contents).unwrap();
            builder.insert(name, oid, 0o100644).unwrap();
        }
        let tree_oid = builder.write().unwrap();
        let tree = repo.find_tree(tree_oid).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

        (repo, tmp)
    }

    #[test]
    fn no_readme_returns_none() {
        let (repo, _tmp) = repo_with_files(&[("src/lib.rs", b"fn main() {}")]);
        assert!(load_readme(&repo).is_none());
    }
}
```

- [ ] **Step 2: Wire the module into `repo/mod.rs`**

In `src/server/http/repo/mod.rs`, add `mod readme;` next to the other `mod` lines (around line 6, after `mod issues;`). Do NOT add a `pub use` yet — `overview.rs` will reach in via `super::readme` in Task 9.

- [ ] **Step 3: Run the test, see it fail**

Run: `cargo test -p git-collab readme::tests::no_readme_returns_none`
Expected: panic with `not yet implemented: Task 2 implements the no-README path`.

- [ ] **Step 4: Implement the minimal no-README path**

Replace the `todo!` body in `load_readme` with:

```rust
pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
    let head = repo.head().ok()?;
    let commit = head.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;

    let _entry = find_readme_entry(&tree)?;
    None // Task 3 will replace this
}

/// One root-tree entry. Carries the resolved category so the renderer can
/// branch on type without re-parsing the filename.
#[derive(Debug)]
struct ReadmeEntry {
    name: String,
    oid: git2::Oid,
    kind: ReadmeKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadmeKind {
    Markdown,
    Plain,
}

/// Walk the root tree (non-recursive) and pick the highest-precedence README
/// blob entry. Symlinks and tree entries are ignored.
fn find_readme_entry(_tree: &git2::Tree) -> Option<ReadmeEntry> {
    None // Task 3 implements the real walk
}
```

- [ ] **Step 5: Run the test again, see it pass**

Run: `cargo test -p git-collab readme::tests::no_readme_returns_none`
Expected: 1 passed.

- [ ] **Step 6: Commit**

```bash
git add src/server/http/repo/readme.rs src/server/http/repo/mod.rs
git commit -m "feat(server): scaffold readme module + no-readme test"
```

---

## Task 3: Lookup precedence (the seven lookup tests)

**Files:**
- Modify: `src/server/http/repo/readme.rs`

- [ ] **Step 1: Add the failing tests for lookup**

Append to the `tests` module in `src/server/http/repo/readme.rs`:

```rust
#[test]
fn finds_uppercase_readme_md() {
    let (repo, _tmp) = repo_with_files(&[("README.md", b"# Title\n\nbody\n")]);
    let r = load_readme(&repo).expect("README found");
    assert!(r.html.contains("<h1>Title</h1>"), "got: {}", r.html);
}

#[test]
fn finds_mixed_case_readme_md() {
    let (repo, _tmp) = repo_with_files(&[("Readme.MD", b"# T\n")]);
    assert!(load_readme(&repo).is_some());
}

#[test]
fn md_wins_over_txt() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"# md\n"),
        ("README.txt", b"plain"),
    ]);
    let r = load_readme(&repo).unwrap();
    assert!(r.html.contains("<h1>md</h1>"));
    assert!(!r.html.contains("plain"));
}

#[test]
fn readme_wins_over_txt() {
    let (repo, _tmp) = repo_with_files(&[
        ("README", b"plain readme"),
        ("README.txt", b"plain txt"),
    ]);
    let r = load_readme(&repo).unwrap();
    assert!(r.html.contains("plain readme"));
    assert!(!r.html.contains("plain txt"));
}

#[test]
fn mixed_case_md_still_wins_over_lowercase_txt() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"# md\n"),
        ("readme.txt", b"plain"),
    ]);
    assert!(load_readme(&repo).unwrap().html.contains("<h1>md</h1>"));
}

#[test]
fn nested_readme_is_not_matched() {
    // Note: tree is built non-recursively here, so we put the nested README
    // in a subtree manually.
    let tmp = TempDir::new().unwrap();
    let repo = Repository::init(tmp.path()).unwrap();
    let sig = git2::Signature::now("T", "t@e").unwrap();

    let blob = repo.blob(b"# nope\n").unwrap();
    let mut sub = repo.treebuilder(None).unwrap();
    sub.insert("README.md", blob, 0o100644).unwrap();
    let sub_oid = sub.write().unwrap();

    let mut root = repo.treebuilder(None).unwrap();
    root.insert("docs", sub_oid, 0o040000).unwrap();
    let root_oid = root.write().unwrap();
    let tree = repo.find_tree(root_oid).unwrap();
    repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

    assert!(load_readme(&repo).is_none());
}

#[test]
fn symlink_readme_is_ignored() {
    let tmp = TempDir::new().unwrap();
    let repo = Repository::init(tmp.path()).unwrap();
    let sig = git2::Signature::now("T", "t@e").unwrap();

    // Symlinks are stored as a blob whose contents are the target path,
    // with file mode 0o120000.
    let target_blob = repo.blob(b"docs/REAL.md").unwrap();
    let mut root = repo.treebuilder(None).unwrap();
    root.insert("README.md", target_blob, 0o120000).unwrap();
    let root_oid = root.write().unwrap();
    let tree = repo.find_tree(root_oid).unwrap();
    repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

    assert!(load_readme(&repo).is_none());
}
```

- [ ] **Step 2: Run them, see them fail**

Run: `cargo test -p git-collab readme::tests`
Expected: the new tests fail (most assert `Some(...)` against the current stub which returns `None`); `no_readme_returns_none` still passes.

- [ ] **Step 3: Implement the lookup**

Replace `find_readme_entry` in `readme.rs`:

```rust
fn find_readme_entry(tree: &git2::Tree) -> Option<ReadmeEntry> {
    // Score: lower is better. None means "not a README".
    fn classify(name: &str) -> Option<(u8, ReadmeKind)> {
        match name.to_ascii_lowercase().as_str() {
            "readme.md" => Some((0, ReadmeKind::Markdown)),
            "readme" => Some((1, ReadmeKind::Plain)),
            "readme.txt" => Some((2, ReadmeKind::Plain)),
            _ => None,
        }
    }

    let mut best: Option<(u8, ReadmeEntry)> = None;
    for entry in tree.iter() {
        // Skip subtrees, symlinks, submodules — only regular blobs.
        if entry.kind() != Some(git2::ObjectType::Blob) {
            continue;
        }
        if entry.filemode() != 0o100644 && entry.filemode() != 0o100755 {
            continue; // 0o120000 (symlink) and anything else
        }
        let name = match entry.name() {
            Some(n) => n,
            None => continue,
        };
        let (score, kind) = match classify(name) {
            Some(v) => v,
            None => continue,
        };
        let candidate = ReadmeEntry { name: name.to_string(), oid: entry.id(), kind };
        match &best {
            None => best = Some((score, candidate)),
            Some((cur_score, _)) if score < *cur_score => best = Some((score, candidate)),
            _ => {}
        }
    }
    best.map(|(_, e)| e)
}
```

And update `load_readme` to actually render markdown blobs (placeholder plain rendering will come in Task 4 — for now make markdown work end-to-end so the lookup tests can verify content):

```rust
pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
    let head = repo.head().ok()?;
    let commit = head.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;
    let entry = find_readme_entry(&tree)?;

    let blob = repo.find_blob(entry.oid).ok()?;
    if blob.is_binary() {
        return None;
    }
    let text = std::str::from_utf8(blob.content()).ok()?;

    let html = match entry.kind {
        ReadmeKind::Markdown => render_markdown(text),
        ReadmeKind::Plain => render_plain(text),
    };
    Some(RenderedReadme { html })
}

fn render_markdown(src: &str) -> String {
    use pulldown_cmark::{Options, Parser, html};
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TASKLISTS);
    let parser = Parser::new_ext(src, opts);
    let mut unsafe_html = String::new();
    html::push_html(&mut unsafe_html, parser);
    sanitize(&unsafe_html)
}

fn render_plain(src: &str) -> String {
    // Task 4 will tighten this. For now, escape and wrap.
    let escaped = html_escape(src);
    format!("<pre>{}</pre>", escaped)
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn sanitize(html: &str) -> String {
    // Task 5 will tighten the policy (img URL schemes). Default is fine for now.
    ammonia::clean(html)
}
```

- [ ] **Step 4: Run the test module, see all 7 lookup tests pass**

Run: `cargo test -p git-collab readme::tests`
Expected: 8 passed (the 7 new + the original `no_readme_returns_none`).

- [ ] **Step 5: Commit**

```bash
git add src/server/http/repo/readme.rs
git commit -m "feat(server): readme lookup with precedence and markdown rendering"
```

---

## Task 4: Plain-text rendering test

**Files:**
- Modify: `src/server/http/repo/readme.rs`

The plain-text path is already wired through `render_plain`. This task adds a test that exercises the escape behavior on a `<script>`-bearing plain README and locks it in.

- [ ] **Step 1: Add the test**

Append to the `tests` module:

```rust
#[test]
fn plain_readme_escapes_script_tag() {
    let (repo, _tmp) = repo_with_files(&[
        ("README", b"<script>alert(1)</script>\nhello"),
    ]);
    let r = load_readme(&repo).unwrap();
    assert!(r.html.starts_with("<pre>"));
    assert!(r.html.contains("&lt;script&gt;"));
    assert!(!r.html.contains("<script>"));
    assert!(r.html.contains("hello"));
}
```

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

Run: `cargo test -p git-collab readme::tests::plain_readme_escapes_script_tag`
Expected: PASS (already implemented by Task 3).

- [ ] **Step 3: Commit (if anything changed)**

Nothing to commit unless the test required code changes. Skip the commit step if there's no diff.

---

## Task 5: Sanitization (script, javascript:, onerror, iframe, data: img)

**Files:**
- Modify: `src/server/http/repo/readme.rs`

- [ ] **Step 1: Add the failing sanitizer tests**

Append to the `tests` module:

```rust
#[test]
fn markdown_strips_script_tag() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"# t\n\n<script>alert(1)</script>\n"),
    ]);
    let html = load_readme(&repo).unwrap().html;
    assert!(!html.contains("<script>"), "got: {}", html);
}

#[test]
fn markdown_strips_javascript_href() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"[click](javascript:alert(1))\n"),
    ]);
    let html = load_readme(&repo).unwrap().html;
    assert!(!html.contains("javascript:"), "got: {}", html);
}

#[test]
fn markdown_strips_onerror_attribute() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"<img src=\"https://x/y.png\" onerror=\"alert(1)\">\n"),
    ]);
    let html = load_readme(&repo).unwrap().html;
    assert!(!html.contains("onerror"), "got: {}", html);
}

#[test]
fn markdown_strips_iframe() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"<iframe src=\"https://evil.example/\"></iframe>\n"),
    ]);
    let html = load_readme(&repo).unwrap().html;
    assert!(!html.contains("<iframe"), "got: {}", html);
}

#[test]
fn markdown_strips_data_image_uri() {
    let (repo, _tmp) = repo_with_files(&[
        ("README.md", b"<img src=\"data:image/png;base64,AAAA\">\n"),
    ]);
    let html = load_readme(&repo).unwrap().html;
    // Either the whole <img> is dropped or the src attr is gone.
    assert!(!html.contains("data:"), "got: {}", html);
}
```

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

Run: `cargo test -p git-collab readme::tests`
Expected: most pass with default ammonia (script, javascript:, onerror, iframe). The `data:` test **fails** because ammonia's default `<img>` URL scheme allowlist includes `data`.

- [ ] **Step 3: Tighten the sanitizer to drop `data:` from `<img src>`**

Replace the `sanitize` function in `readme.rs` with a `Builder`-based one:

```rust
fn sanitize(html: &str) -> String {
    use std::collections::HashSet;

    // Restrict <img src> URL schemes to http/https only — no data:, no javascript:.
    // ammonia::Builder::url_schemes() applies the allowlist to ALL URL-bearing
    // attributes, which is what we want.
    let mut schemes: HashSet<&str> = HashSet::new();
    schemes.insert("http");
    schemes.insert("https");
    schemes.insert("mailto");

    ammonia::Builder::default()
        .url_schemes(schemes)
        .clean(html)
        .to_string()
}
```

- [ ] **Step 4: Re-run the tests**

Run: `cargo test -p git-collab readme::tests`
Expected: all sanitizer tests pass. Lookup tests still pass.

- [ ] **Step 5: Commit**

```bash
git add src/server/http/repo/readme.rs
git commit -m "feat(server): tighten readme sanitizer img URL schemes"
```

---

## Task 6: Size cap, UTF-8 strict, post-render bomb cap

**Files:**
- Modify: `src/server/http/repo/readme.rs`

- [ ] **Step 1: Widen `load_readme` signature to take repo name + branch**

The "too large" notice needs to render a link of the form
`/{repo_name}/blob/{branch}/{file_name}`. `load_readme` is pure (no AppState), so
it must receive both `repo_name` and `branch` as arguments. Change the public
signature in `readme.rs`:

```rust
pub fn load_readme(
    repo: &Repository,
    repo_name: &str,
    branch: &str,
) -> Option<RenderedReadme> { /* existing body unchanged for now */ }
```

Add a small test helper at the top of the `tests` module so the existing tests
do not have to know about the new arguments:

```rust
fn load(repo: &Repository) -> Option<RenderedReadme> {
    let branch = repo.head().ok()
        .and_then(|h| h.shorthand().map(String::from))
        .unwrap_or_else(|| "main".to_string());
    load_readme(repo, "test-repo", &branch)
}
```

…then replace every existing `load_readme(&repo)` call in the tests with
`load(&repo)`. Run `cargo test -p git-collab readme::tests` to confirm
nothing regressed.

- [ ] **Step 2: Add the size constants**

Near the top of `readme.rs`, after the type definitions, add:

```rust
const MAX_BLOB_BYTES: usize = 512 * 1024;
const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
```

- [ ] **Step 3: Add the size / UTF-8 / bomb tests**

Append to the `tests` module:

```rust
#[test]
fn empty_readme_renders_empty_body() {
    let (repo, _tmp) = repo_with_files(&[("README.md", b"")]);
    let r = load(&repo).expect("present-but-empty is Some");
    // ammonia of empty markdown is the empty string; assert it's not the
    // too-large notice and not None.
    assert!(!r.html.contains("too large"));
}

#[test]
fn oversized_blob_returns_too_large_notice() {
    let big = vec![b'x'; 600 * 1024];
    let (repo, _tmp) = repo_with_files(&[("README.md", &big)]);
    let r = load(&repo).unwrap();
    assert!(r.html.contains("too large"));
    assert!(r.html.contains("/blob/"));
    assert!(r.html.contains("README.md"));
}

#[test]
fn markdown_bomb_post_render_cap_trips() {
    // A long table row replicated many times: small source, huge HTML.
    let mut src = String::from("| a | b |\n|---|---|\n");
    for _ in 0..200_000 {
        src.push_str("| xxxxxxxxxxxx | yyyyyyyyyyyy |\n");
    }
    // Source is well under 512 KiB but rendered HTML will exceed 2 MiB.
    assert!(src.len() < MAX_BLOB_BYTES);
    let (repo, _tmp) = repo_with_files(&[("README.md", src.as_bytes())]);
    let r = load(&repo).unwrap();
    assert!(r.html.contains("too large"), "expected bomb to trip cap");
}

#[test]
fn binary_blob_returns_none() {
    let (repo, _tmp) = repo_with_files(&[("README.md", &[0u8, 1, 2, 3, 0xff, 0xfe])]);
    assert!(load(&repo).is_none());
}

#[test]
fn invalid_utf8_returns_none() {
    // Mostly valid text + a stray 0x80 byte. Not flagged as binary by git2's
    // heuristic (no NULs), but not valid UTF-8 either.
    let mut bytes = Vec::from(&b"hello world\nmore text\n"[..]);
    bytes.push(0x80);
    bytes.extend_from_slice(b"\nmore\n");
    let (repo, _tmp) = repo_with_files(&[("README.md", &bytes)]);
    assert!(load(&repo).is_none());
}
```

- [ ] **Step 4: Run them, see them fail**

Run: `cargo test -p git-collab readme::tests`
Expected: oversized + bomb tests fail (current code doesn't cap). The other new tests may pass already.

- [ ] **Step 5: Implement the caps and the notice helper**

Add the notice helper and replace the body of `load_readme` with the full version:

```rust
pub fn load_readme(
    repo: &Repository,
    repo_name: &str,
    branch: &str,
) -> Option<RenderedReadme> {
    let head = repo.head().ok()?;
    let commit = head.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;
    let entry = find_readme_entry(&tree)?;

    let blob = match repo.find_blob(entry.oid) {
        Ok(b) => b,
        Err(err) => {
            tracing::warn!(repo = repo_name, error = %err, "failed to load readme blob");
            return None;
        }
    };

    if blob.is_binary() {
        return None;
    }

    if blob.size() > MAX_BLOB_BYTES {
        return Some(RenderedReadme {
            html: too_large_notice_with_repo(repo_name, branch, &entry.name),
        });
    }

    let text = std::str::from_utf8(blob.content()).ok()?;

    let html = match entry.kind {
        ReadmeKind::Markdown => {
            let rendered = render_markdown(text);
            if rendered.len() > MAX_HTML_BYTES {
                too_large_notice_with_repo(repo_name, branch, &entry.name)
            } else {
                rendered
            }
        }
        ReadmeKind::Plain => render_plain(text),
    };
    Some(RenderedReadme { html })
}

fn too_large_notice_with_repo(repo_name: &str, branch: &str, file_name: &str) -> String {
    format!(
        "<p><em>README too large to render. \
         <a href=\"/{repo}/blob/{branch}/{name}\">View raw</a>.</em></p>",
        repo = html_escape(repo_name),
        branch = html_escape(branch),
        name = html_escape(file_name),
    )
}
```

- [ ] **Step 6: Re-run all readme tests**

Run: `cargo test -p git-collab readme::tests`
Expected: all tests pass. If the bomb test does not actually exceed 2 MiB of HTML, increase the loop count until it does.

- [ ] **Step 7: Commit**

```bash
git add src/server/http/repo/readme.rs
git commit -m "feat(server): readme size caps, UTF-8 strict, blob link"
```

---

## Task 7: Wire `load_readme` into the overview handler

**Files:**
- Modify: `src/server/http/repo/overview.rs`
- Modify: `src/server/http/repo/mod.rs`

- [ ] **Step 1: Re-export the type if needed**

In `src/server/http/repo/mod.rs`, the existing `mod readme;` is enough. Inside `overview.rs` we'll refer to it via `super::readme`.

- [ ] **Step 2: Update `OverviewTemplate` and the handler**

In `src/server/http/repo/overview.rs`, update the imports and template struct:

```rust
use super::{
    AppState, OverviewCommit, collab_counts, head_branch_name, open_repo, recent_commits,
    readme::{self, RenderedReadme},
};
```

Add the field to `OverviewTemplate`:

```rust
#[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 readme: Option<RenderedReadme>,
    pub commits: Vec<OverviewCommit>,
    pub patches: Vec<OverviewPatch>,
    pub issues: Vec<OverviewIssue>,
}
```

In the `overview` async function, after `let commits = recent_commits(&repo, 10);`, add:

```rust
    let branch = head_branch_name(&repo);
    let readme = readme::load_readme(&repo, &repo_name, &branch);
```

…and include `readme` in the struct literal.

- [ ] **Step 3: Verify it compiles**

Run: `cargo build`
Expected: clean build. (Template still references the old fields — adding a new optional one won't break it.)

- [ ] **Step 4: Commit**

```bash
git add src/server/http/repo/overview.rs src/server/http/repo/mod.rs
git commit -m "feat(server): plumb readme into overview handler"
```

---

## Task 8: Render the README in the overview template

**Files:**
- Modify: `src/server/http/templates/repo_overview.html`

- [ ] **Step 1: Add the readme card above the patches/issues grid**

Insert this block at the top of the `{% block content %}` section, **before** the existing `<div style="display: grid; ...">`:

```html
{% if let Some(r) = readme %}
<div style="border: 1px solid #ccc; padding: 16px; margin-bottom: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.5;">
  <h3 style="margin-top: 0; font-family: monospace;">README</h3>
  <div class="readme-body">{{ r.html|safe }}</div>
</div>
{% endif %}
```

The `font-family: ... sans-serif` override is intentional: the rest of the site uses monospace, but rendered prose looks ugly in monospace. The `<h3>` keeps the monospace face to match the section headings on the rest of the page.

- [ ] **Step 2: Build and visually verify**

Run: `cargo build`
Expected: clean build (askama compiles the template at build time).

Then start the server against a test repo (or use whatever the project's standard "run a dev server" command is — check `README.md` or `Cargo.toml` `[[bin]]` entries) and load `http://localhost:<port>/<repo>` for a repo that has a `README.md`. Confirm:
- README appears above patches/issues
- Markdown is rendered (heading is bold, lists render, etc.)
- A repo with no README still renders the page exactly as before

If you cannot run the dev server in the current environment, **say so explicitly** and rely on the integration test in Task 9 instead of claiming visual success.

- [ ] **Step 3: Commit**

```bash
git add src/server/http/templates/repo_overview.html
git commit -m "feat(server): render readme card on repo overview"
```

---

## Task 9: Integration test via ServerHarness

**Files:**
- Modify: `tests/server_behavior_test.rs`

- [ ] **Step 1: Add the failing test**

Append a new `#[test]` function to `tests/server_behavior_test.rs`:

```rust
#[test]
fn readme_md_renders_on_repo_overview_page() {
    let harness = ServerHarness::new("behavior-readme");

    harness.work_repo().commit_file(
        "README.md",
        "# Hello World\n\nA short description with a [link](https://example.com).\n",
        "add README",
    );
    harness.push_head();

    let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
    assert!(overview.body.contains("<h1>Hello World</h1>"), "missing h1: {}", overview.body);
    assert!(overview.body.contains("href=\"https://example.com\""));
    // Sanity: the new card wrapper exists.
    assert!(overview.body.contains("class=\"readme-body\""));
}

#[test]
fn missing_readme_does_not_break_overview_page() {
    let harness = ServerHarness::new("behavior-no-readme");

    harness.work_repo().commit_file(
        "src/lib.rs",
        "pub fn x() {}\n",
        "add lib",
    );
    harness.push_head();

    let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
    assert!(!overview.body.contains("class=\"readme-body\""));
    // The rest of the page still renders.
    assert!(overview.body.contains("Open Patches") || overview.body.contains("Recent Commits"));
}
```

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

Run: `cargo test --test server_behavior_test readme`
Expected: both tests pass. (The handler change in Task 7 + template change in Task 8 are now exercised end-to-end.)

- [ ] **Step 3: Run the full suite to make sure nothing else regressed**

Run: `cargo test`
Expected: all tests pass. If the pre-existing modifications to `issues.rs` / `patches.rs` cause failures, that's unrelated to this work — leave them alone and surface the failures to the user rather than fixing them.

- [ ] **Step 4: Commit**

```bash
git add tests/server_behavior_test.rs
git commit -m "test(server): integration tests for readme rendering on overview"
```

---

## Task 10: Lint pass

**Files:** none (or whatever clippy flags)

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

Run: `cargo clippy --all-targets -- -D warnings`
Expected: clean. If clippy flags anything in `readme.rs` or the handler, fix it inline (do not silence with `#[allow]` unless the lint is genuinely wrong for the situation).

- [ ] **Step 2: Commit any clippy fixes**

```bash
git add -u
git commit -m "chore(server): clippy fixes for readme module"
```

Skip if there are no fixes.

---

## Acceptance gate (run before declaring done)

- [ ] `cargo test -p git-collab readme::` — all unit tests pass
- [ ] `cargo test --test server_behavior_test readme` — both integration tests pass
- [ ] `cargo test` — full suite green (modulo pre-existing unrelated failures in `issues.rs` / `patches.rs`)
- [ ] `cargo clippy --all-targets -- -D warnings` — clean
- [ ] Manual visit (if a dev server is available): repo with `README.md` shows rendered README; repo without one is unchanged
- [ ] Spec checklist:
  - [ ] Lookup precedence and case-insensitivity ✅ Tasks 3
  - [ ] Markdown via pulldown-cmark + ammonia with tightened img schemes ✅ Tasks 3, 5
  - [ ] Plain-text fallback ✅ Tasks 3, 4
  - [ ] 512 KiB blob cap + 2 MiB post-render cap ✅ Task 6
  - [ ] Strict UTF-8 decode ✅ Task 6
  - [ ] Symlink and binary entries ignored ✅ Tasks 3, 6
  - [ ] Blob link uses resolved branch name, not literal "HEAD" ✅ Tasks 6, 7
  - [ ] Bordered card with `<h3>README</h3>` ✅ Task 8
  - [ ] Warn-once on unexpected git2 errors ✅ Task 6
  - [ ] All 19 spec test cases mapped to actual tests ✅ Tasks 2, 3, 4, 5, 6