git-ents.gitmain
⌘K
foforge
commit e5c4525
feat: add server-rendered web UI for browsing repositories

A plain browser GET is now served a GitHub-style HTML page styled with Primer CSS; git wire-protocol requests still go to the http-backend CGI. The index lists every bare repository under the data directory and each repository page shows its default branch, clone URL, root tree, and recent commits.

feat: render repository index and overview pages with Maud and Primer CSS feat: dispatch non-protocol browser GETs to the web UI in the git fallback deps: add maud with the axum feature Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 2 months ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -297,6 +297,7 @@ "axum", "clap", "clap_mangen", + "maud", "rstest", "tempfile", "tokio", @@ -446,6 +447,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "axum-core", + "http", + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.8.2" @@ -511,6 +536,18 @@ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + [[package]] name = "quote" version = "1.0.45" @@ -880,6 +917,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1"
Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" gix = { version = "0.83", features = ["no-default-features"] } +maud = { version = "0.27", features = ["axum"] } rstest = "0.26" tempfile = "3" thiserror = "2"
crates/git-ents-server/Cargo.toml @@ -9,6 +9,7 @@ axum = { workspace = true } clap = { workspace = true } clap_mangen = { workspace = true } +maud = { workspace = true } tokio = { workspace = true } [dev-dependencies]
crates/git-ents-server/src/http.rs @@ -39,6 +39,13 @@ return (StatusCode::BAD_REQUEST, "bad request").into_response(); } + // A plain browser GET (anything that is not part of the git wire protocol) + // is served the HTML web UI rather than handed to the CGI backend. + if method == Method::GET && !is_git_path(&path_info, &query_string) { + let host = header_value(&headers, "Host"); + return crate::web::render(&state, &path_info, host.as_deref()).await; + } + // A push uses exactly two endpoints: the receive-pack advertisement // (`GET /<repo>/info/refs?service=git-receive-pack`) and the receive-pack // RPC (`POST /<repo>/git-receive-pack`). Recognize the target so the bare @@ -173,6 +180,18 @@ /// Greatest repository nesting depth: `repo`, `org/repo`, or `org/team/repo`. const MAX_REPO_DEPTH: usize = 3; +/// Whether `path`/`query` belong to git's wire protocol (smart or dumb HTTP) +/// rather than the browser-facing web UI. Anything matching here is delegated +/// to `git http-backend`; everything else is rendered as HTML. +fn is_git_path(path: &str, query: &str) -> bool { + path.ends_with("/info/refs") + || path.ends_with("/git-upload-pack") + || path.ends_with("/git-receive-pack") + || path.ends_with("/HEAD") + || path.contains("/objects/") + || query.contains("service=") +} + /// Whether this request is a push: the smart-HTTP receive-pack advertisement /// (`/info/refs?service=git-receive-pack`) or the receive-pack RPC itself. /// @@ -245,7 +264,7 @@ } /// Whether `path` is the root of a bare git repository. -fn is_bare_repo(path: &Path) -> bool { +pub(crate) fn is_bare_repo(path: &Path) -> bool { path.join("HEAD").is_file() && path.join("objects").is_dir() } @@ -277,7 +296,7 @@ /// Rejecting any leading `.` rules out `.`, `..`, and hidden directories; the /// allow-list of characters rules out path separators, NUL, percent-encoding, /// and whitespace, so no segment can traverse or otherwise escape on disk. -fn valid_segment(segment: &str) -> bool { +pub(crate) fn valid_segment(segment: &str) -> bool { !segment.is_empty() && !segment.starts_with('.') && segment
crates/git-ents-server/src/main.rs @@ -1,6 +1,7 @@ //! Git Ents server — helpful guardians of your git trees. mod http; +mod web; use std::net::SocketAddr; use std::path::PathBuf; @@ -77,7 +78,6 @@ // The git smart-HTTP protocol streams whole packfiles through the request // body, so the default 2 MiB cap would reject any non-trivial push. let mut app = Router::new() - .route("/", get(http::health)) .route("/healthz", get(http::health)) .fallback(http::git) .layer(DefaultBodyLimit::disable())
crates/git-ents-server/src/web.rs @@ -1,0 +1,298 @@ +//! Browser-facing HTML: a small GitHub-style web UI rendered server-side with +//! Maud and styled with Primer CSS (GitHub's own design system). The git +//! smart-HTTP gateway in [`crate::http`] delegates plain browser GETs here. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use maud::{DOCTYPE, Markup, html}; +use tokio::process::Command; + +use crate::AppState; +use crate::http::{is_bare_repo, valid_segment}; + +/// Greatest repository nesting depth served: `repo`, `org/repo`, `org/team/repo`. +const MAX_DEPTH: usize = 3; + +/// Pinned Primer CSS so the look does not drift with upstream releases. +const PRIMER_CSS: &str = "https://unpkg.com/@primer/css@21.5.0/dist/primer.css"; + +/// Render the page for `path`: the repository index at the root, or a single +/// repository's overview otherwise. `host` is the request's `Host` header, used +/// to build a copy-pasteable clone URL. +pub(crate) async fn render(state: &AppState, path: &str, host: Option<&str>) -> Response { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segments.is_empty() { + return index(state).into_response(); + } + if segments.len() > MAX_DEPTH || !segments.iter().all(|s| valid_segment(s)) { + return not_found().into_response(); + } + + let relative: PathBuf = segments.iter().collect(); + let repo = state.data_dir.join(&relative); + if !is_bare_repo(&repo) { + return not_found().into_response(); + } + + let rel_str = segments.join("/"); + repo_page(&repo, &rel_str, host).await.into_response() +} + +/// The repository listing shown at `/`. +fn index(state: &AppState) -> Markup { + let repos = discover_repos(&state.data_dir); + page( + "Repositories", + html! { + div."d-flex"."flex-items-center"."mb-3" { + h2.f3.text-normal."flex-auto" { "Repositories" } + } + @if repos.is_empty() { + div.blankslate."color-bg-subtle"."rounded-2" { + h3.blankslate-heading { "No repositories yet" } + p { "Push to this server to create one:" } + p { code { "git push <url>/my-repo.git HEAD" } } + } + } @else { + div.Box { + @for repo in &repos { + div.Box-row."d-flex"."flex-items-center" { + span.mr-2 { "📦" } + a.text-bold.flex-auto href={ "/" (repo) } { (repo) } + span.Label."Label--secondary" { "git" } + } + } + } + } + }, + ) +} + +/// A single repository's overview: default branch, recent commits, root tree. +async fn repo_page(repo: &Path, rel: &str, host: Option<&str>) -> Markup { + let branch = git_output(repo, &["symbolic-ref", "--short", "HEAD"]) + .await + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); + let commits = recent_commits(repo).await; + let tree = root_tree(repo, branch.is_some()).await; + let clone_url = clone_url(host, rel); + let name = rel.rsplit('/').next().unwrap_or(rel); + + page( + name, + html! { + div."d-flex"."flex-items-center"."mb-3" { + h2.f3.text-normal."flex-auto" { + "📂 " a.text-bold href={ "/" (rel) } { (rel) } + @if let Some(branch) = &branch { + " " span.Label."Label--accent" { (branch) } + } + } + } + + div.Box."mb-4" { + div.Box-header { span.text-bold { "Clone" } } + div.Box-body { code { "git clone " (clone_url) } } + } + + @if commits.is_empty() { + div.blankslate."color-bg-subtle"."rounded-2" { + h3.blankslate-heading { "This repository is empty" } + p { "Push a commit to get started." } + } + } @else { + @if !tree.is_empty() { + div.Box."mb-4" { + div.Box-header { span.text-bold { "Files" } } + @for entry in &tree { + div.Box-row { + span.mr-2 { (if entry.is_dir { "📁" } else { "📄" }) } + span { (entry.name) } + } + } + } + } + div.Box { + div.Box-header { span.text-bold { "Recent commits" } } + @for commit in &commits { + div.Box-row { + div.text-bold { (commit.subject) } + div."color-fg-muted".f6 { + code.mr-2 { (commit.short) } + (commit.author) " · " (commit.when) + } + } + } + } + } + }, + ) +} + +/// A parsed `git log` entry. +struct Commit { + short: String, + author: String, + when: String, + subject: String, +} + +/// The most recent commits reachable from `HEAD`, newest first. +async fn recent_commits(repo: &Path) -> Vec<Commit> { + let Some(log) = git_output( + repo, + &["log", "-n", "20", "--format=%H%x00%an%x00%ar%x00%s"], + ) + .await + else { + return Vec::new(); + }; + log.lines() + .filter_map(|line| { + let mut parts = line.split('\u{0}'); + let hash = parts.next().unwrap_or_default(); + let author = parts.next().unwrap_or_default(); + let when = parts.next().unwrap_or_default(); + let subject = parts.next().unwrap_or_default(); + if hash.is_empty() { + return None; + } + Some(Commit { + short: hash.get(..7).unwrap_or(hash).to_owned(), + author: author.to_owned(), + when: when.to_owned(), + subject: subject.to_owned(), + }) + }) + .collect() +} + +/// A single entry in the repository's root tree. +struct TreeEntry { + name: String, + is_dir: bool, +} + +/// The entries of the root tree at `HEAD`, directories first then by name. +async fn root_tree(repo: &Path, has_head: bool) -> Vec<TreeEntry> { + if !has_head { + return Vec::new(); + } + let Some(out) = git_output(repo, &["ls-tree", "HEAD"]).await else { + return Vec::new(); + }; + let mut entries: Vec<TreeEntry> = out + .lines() + .filter_map(|line| { + let (meta, name) = line.split_once('\t')?; + let kind = meta.split(' ').nth(1).unwrap_or_default(); + Some(TreeEntry { + name: name.to_owned(), + is_dir: kind == "tree", + }) + }) + .collect(); + entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); + entries +} + +/// Run `git -C <repo> <args>` and return its stdout, or `None` on failure. +async fn git_output(repo: &Path, args: &[&str]) -> Option<String> { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .stderr(Stdio::null()) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// All bare repositories under `root`, as relative slash paths, sorted. +fn discover_repos(root: &Path) -> Vec<String> { + let mut repos = Vec::new(); + collect_repos(root, root, MAX_DEPTH, &mut repos); + repos.sort(); + repos +} + +/// Recurse into `dir` (up to `depth` levels) collecting bare repositories. +fn collect_repos(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) { + if depth == 0 { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if is_bare_repo(&path) { + if let Ok(rel) = path.strip_prefix(root) { + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } else { + collect_repos(root, &path, depth.saturating_sub(1), out); + } + } +} + +/// The clone URL for `rel`, using the request host when known. +fn clone_url(host: Option<&str>, rel: &str) -> String { + match host { + Some(host) => format!("http://{host}/{rel}"), + None => format!("/{rel}"), + } +} + +/// A `404` page. +fn not_found() -> (StatusCode, Markup) { + ( + StatusCode::NOT_FOUND, + page( + "Not found", + html! { + div.blankslate { + h3.blankslate-heading { "404" } + p { "No such repository." } + a.btn href="/" { "Back to repositories" } + } + }, + ), + ) +} + +/// Wrap page `body` in the shared HTML shell, navigation, and Primer styling. +fn page(title: &str, body: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) " · Git Ents" } + link rel="stylesheet" href=(PRIMER_CSS); + } + body."color-bg-default"."color-fg-default" { + header.Header."color-bg-inset" { + div.Header-item { + a.Header-link.f4.text-bold href="/" { "🌳 Git Ents" } + } + } + div."container-lg"."p-responsive"."my-5" { + (body) + } + } + } + } +}