docs/superpowers/plans/2026-03-30-git-collab-server.md
Ref: Size: 86.3 KiB
# 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"
```