feat: add server-rendered web UI for browsing repositories
commit
e5c4525feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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)
+ }
+ }
+ }
+ }
+}