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 }} · {{ 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 }} · {{ 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> · {{ patch.author }} · {{ patch.branch }} → {{ patch.base_ref }} · 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);"> · {{ 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 %} · {{ 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> · {{ 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> · {{ issue.author }} · created {{ issue.created_at }} {% if !issue.labels.is_empty() %} · [{{ issue.labels }}]{% endif %} {% if !issue.assignees.is_empty() %} · 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> · {{ 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" ```