4113ca5a

Archive
Tree [4113ca5a] [>]
commit
4113ca5a496dbcc63d766f82762a8eb64ee0d8ac
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
changes
20
insertions
827
deletions
472
Extract service layer and prepare for 1.0
MCargo.lock
~]~~[[package]]+name = "async-compression"+version = "0.4.42"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"+dependencies = [+ "compression-codecs",+ "compression-core",+ "pin-project-lite",+ "tokio",+]++[[package]]~name = "atomic-waker"~version = "1.1.2"~source = "registry+https://github.com/rust-lang/crates.io-index"
~version = "1.0.5"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "compression-codecs"+version = "0.4.38"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"+dependencies = [+ "compression-core",+ "flate2",+ "memchr",+]++[[package]]+name = "compression-core"+version = "0.4.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"~~[[package]]~name = "cpufeatures"
~checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"~dependencies = [~ "percent-encoding",+]++[[package]]+name = "futures"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"+dependencies = [+ "futures-channel",+ "futures-core",+ "futures-executor",+ "futures-io",+ "futures-sink",+ "futures-task",+ "futures-util",~]~~[[package]]
~checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"~dependencies = [~ "futures-core",+ "futures-sink",~]~~[[package]]
~version = "0.3.32"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-executor"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"+dependencies = [+ "futures-core",+ "futures-task",+ "futures-util",+]++[[package]]+name = "futures-io"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"++[[package]]+name = "futures-macro"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]~~[[package]]~name = "futures-sink"
~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"~dependencies = [+ "futures-channel",~ "futures-core",+ "futures-io",+ "futures-macro",+ "futures-sink",~ "futures-task",+ "memchr",~ "pin-project-lite",~ "slab",~]
~dependencies = [~ "static_assertions",~]++[[package]]+name = "lazy_static"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"~~[[package]]~name = "leb128fmt"
~version = "0.4.29"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "matchers"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"+dependencies = [+ "regex-automata",+]~~[[package]]~name = "matchit"
~version = "0.12.0"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"++[[package]]+name = "nu-ansi-term"+version = "0.50.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"+dependencies = [+ "windows-sys",+]~~[[package]]~name = "once_cell"
~dependencies = [~ "digest",~ "sha1",+]++[[package]]+name = "sharded-slab"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"+dependencies = [+ "lazy_static",~]~~[[package]]
~dependencies = [~ "askama",~ "askama_web",+ "async-compression",~ "axum",~ "clap",+ "futures",~ "gix",~ "jiff",~ "pulldown-cmark",
~ "thiserror",~ "tokio",~ "tokio-util",+ "tower-http",+ "tracing",+ "tracing-subscriber",~]~~[[package]]
~ "proc-macro2",~ "quote",~ "syn",+]++[[package]]+name = "thread_local"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"+dependencies = [+ "cfg-if",~]~~[[package]]
~ "pin-project-lite",~ "sync_wrapper",~ "tokio",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "tower-http"+version = "0.6.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"+dependencies = [+ "bitflags",+ "bytes",+ "http",+ "http-body",+ "pin-project-lite",~ "tower-layer",~ "tower-service",~ "tracing",
~dependencies = [~ "log",~ "pin-project-lite",+ "tracing-attributes",~ "tracing-core",+]++[[package]]+name = "tracing-attributes"+version = "0.1.31"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",~]~~[[package]]
~version = "0.1.36"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+ "valuable",+]++[[package]]+name = "tracing-log"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"+dependencies = [+ "log",+ "once_cell",+ "tracing-core",+]++[[package]]+name = "tracing-subscriber"+version = "0.3.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"~dependencies = [+ "matchers",+ "nu-ansi-term",~ "once_cell",+ "regex-automata",+ "sharded-slab",+ "smallvec",+ "thread_local",+ "tracing",+ "tracing-core",+ "tracing-log",~]~~[[package]]
~version = "0.2.2"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "valuable"+version = "0.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"~~[[package]]~name = "version_check"
MCargo.toml
~version = "0.1.0"~edition = "2024"~license = "AGPL-3.0-or-later"+description = "Minimalist git web browser with commit graph, blame, and syntax highlighting"+keywords = ["git", "web", "browser", "repository", "axum"]+categories = ["web-programming::http-server", "development-tools::version-control"]+rust-version = "1.85"~~[lib]~name = "teak"
~askama_web = { version = "0.15.2", features = ["axum-0.8"] }~axum = "0.8.9"~clap = { version = "4.6.1", features = ["derive"] }+async-compression = { version = "0.4", features = ["tokio", "gzip"] }+futures = "0.3"~gix = "0.81.0"~jiff = "0.2.24"~pulldown-cmark = "0.13.3"~serde = { version = "1.0.228", features = ["derive"] }~syntect = { version = "5.2", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }~thiserror = "2.0.18"-tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "process", "io-util"] }+tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "process", "io-util", "signal"] }~tokio-util = { version = "0.7", features = ["io"] }+tower-http = { version = "0.6", features = ["trace"] }+tracing = "0.1"+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Msrc/config.rs
~static SSH_PREFIX: OnceLock<String> = OnceLock::new();~static BASE_URL: OnceLock<String> = OnceLock::new();~static FAVICON: OnceLock<Vec<u8>> = OnceLock::new();+static FAVICON_MIME: OnceLock<&'static str> = OnceLock::new();~~/// Returns the configured hostname, or `"index"` if none was provided.~pub fn hostname() -> &'static str {
~    FAVICON.get().map_or(&[], |v| v.as_slice())~}~+/// Returns the MIME type of the favicon.+pub fn favicon_mime_type() -> &'static str {+    FAVICON_MIME.get().copied().unwrap_or("image/svg+xml")+}+~/// Sets the hostname shown in breadcrumbs and clone URLs.~/// Silently skips `None`.~pub fn set_hostname(v: Option<String>) {
~    }~}~-/// Sets the favicon bytes served at `/favicon.ico`.-pub fn set_favicon(v: Vec<u8>) {+/// Sets the favicon bytes and MIME type served at `/favicon.ico`.+pub fn set_favicon(v: Vec<u8>, mime: &'static str) {~    let _ = FAVICON.set(v);+    let _ = FAVICON_MIME.set(mime);~}
Msrc/error.rs
~~impl IntoResponse for Error {~    fn into_response(self) -> Response {+        let is_5xx: bool;~        let (status, kind) = match &self {-            Self::RepoNotFound(_) | Self::NotFound(_) => (StatusCode::NOT_FOUND, "Not Found"),-            Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad Request"),+            Self::RepoNotFound(_) | Self::NotFound(_) => {+                is_5xx = false;+                (StatusCode::NOT_FOUND, "Not Found")+            }+            Self::BadRequest(_) => {+                is_5xx = false;+                (StatusCode::BAD_REQUEST, "Bad Request")+            }~            Self::Io(_) | Self::GitCorrupt(_) => {+                is_5xx = true;+                tracing::error!(error = %self, "internal error");~                (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error")~            }+        };++        let message = if is_5xx {+            "An internal error occurred.".into()+        } else {+            self.to_string()~        };~~        let tpl = ErrorTemplate {~            status: status.as_u16(),~            kind,-            message: self.to_string(),+            message,~            base_url: crate::base_url().to_string(),~        };~~        tpl.render().map_or_else(-            |_| (status, self.to_string()).into_response(),-            |html| ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response(),+            |_| {+                let fallback = if is_5xx {+                    "Internal Server Error".into()+                } else {+                    self.to_string()+                };+                (status, fallback).into_response()+            },+            |html| (status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response(),~        )~    }~}
Msrc/filters.rs
~//! Custom Askama filters.~~/// Returns `singular` if `count == 1`, otherwise `plural`.-#[allow(-    dead_code,-    clippy::trivially_copy_pass_by_ref,-    clippy::unnecessary_wraps-)]~pub fn pluralize(count: &usize, singular: &str, plural: &str) -> askama::Result<String> {~    Ok(if *count == 1 {~        singular.to_string()
Msrc/git.rs
~~use std::path::Path;~+use gix::bstr::ByteSlice;~use jiff::Timestamp;~use jiff::tz::{Offset, TimeZone};~~use crate::error::{Error, Result as CrateResult};~+/// Information about a tag for list views.+pub struct TagInfo {+    pub name: String,+    pub date: String,+    pub timestamp: i64,+}++/// Information about a branch for list views.+pub struct BranchInfo {+    pub name: String,+    pub date: String,+    pub timestamp: i64,+    pub is_default: bool,+}++/// Summary of a commit for log views.+pub struct CommitSummary {+    pub sha: String,+    pub message: String,+    pub author: String,+    pub date: String,+}++/// Annotation data for an annotated tag.+pub struct TagAnnotation {+    pub tagger: Option<String>,+    pub date: Option<String>,+    pub message: Option<String>,+}++/// Basic commit identity info (sha + author + date).+pub struct CommitInfo {+    pub sha: String,+    pub author: String,+    pub date: String,+}++/// Paginated commit log for a branch.+pub struct BranchLog {+    pub tip_sha: String,+    pub commits: Vec<CommitSummary>,+    pub next_cursor: Option<String>,+}++/// A single entry in blame output.+pub struct BlameEntry {+    pub sha: String,+    pub short_sha: String,+    pub color: String,+    pub num: usize,+    pub content: String,+}++/// A single commit entry for the full ancestry graph view.+pub struct CommitEntry {+    pub sha: String,+    pub message: String,+    pub date: String,+    pub parents: Vec<String>,+}+~/// Opens a repository at `root/{name}` or returns [`Error::RepoNotFound`].~pub fn open_repo(root: &Path, name: &str) -> CrateResult<gix::Repository> {~    gix::open(root.join(name)).map_err(|_| Error::RepoNotFound(name.to_owned()))
~    I: IntoIterator<Item = std::result::Result<T, E2>>,~{~    result.into_iter().flatten().flatten()+}++/// Returns the total number of commits reachable from any ref in the+/// repository.+///+/// Collects all ref tips (branches, tags, remote-tracking refs, and detached+/// HEAD), then walks the complete reachable commit graph using gix's+/// commit-graph-aware traversal.  This is consistent regardless of+/// commit-graph availability.+pub fn commit_count(repo: &gix::Repository) -> usize {+    let refs = match repo.references() {+        Ok(r) => r,+        Err(_) => return 0,+    };++    let mut tips = Vec::new();++    for mut reference in flatten_refs(refs.all()) {+        if let Ok(commit) = reference.peel_to_commit() {+            tips.push(commit.id().detach());+        }+    }++    // Include HEAD in case it is detached (not covered by refs.all()).+    if let Ok(head_id) = repo.head_id()+        && let Ok(obj) = head_id.object()+        && let Ok(commit) = obj.try_into_commit()+    {+        tips.push(commit.id().detach());+    }++    tips.sort();+    tips.dedup();++    if tips.is_empty() {+        return 0;+    }++    repo.rev_walk(tips)+        .all()+        .map(|walk| walk.filter_map(Result::ok).count())+        .unwrap_or(0)+}++/// Loads all tags from a repository, sorted newest-first.+/// Errors during iteration are silently skipped.+pub fn load_tags(repo: &gix::Repository) -> Vec<TagInfo> {+    let refs = match repo.references() {+        Ok(r) => r,+        Err(_) => return Vec::new(),+    };+    let mut tags: Vec<TagInfo> = flatten_refs(refs.tags())+        .filter_map(|mut tag| {+            let commit = tag.peel_to_commit().ok()?;+            let date = commit_date(&commit)?;+            let timestamp = commit_timestamp(&commit)?;+            let name = tag.name().shorten().to_string();+            Some(TagInfo { name, date, timestamp })+        })+        .collect();+    tags.sort_by_key(|t| std::cmp::Reverse(t.timestamp));+    tags+}++/// Loads a single tag's annotation and its target commit.+pub fn load_tag(repo: &gix::Repository, tag_name: &str) -> CrateResult<(TagAnnotation, CommitInfo)> {+    let ref_name = format!("refs/tags/{tag_name}");+    let mut reference = repo+        .find_reference(&ref_name)+        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;++    let (tagger, tag_date, message) = reference+        .peel_to_tag()+        .ok()+        .and_then(|t| {+            let decoded = t.decode().ok()?;+            let sig = decoded.tagger().ok().flatten();+            let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());+            let tag_date = sig.as_ref().and_then(|s| {+                let time = s.time().ok()?;+                format_time(time.seconds, time.offset)+            });+            let msg = decoded.message.to_str_lossy().trim().to_string();+            let message = if msg.is_empty() { None } else { Some(msg) };+            Some((tagger, tag_date, message))+        })+        .unwrap_or((None, None, None));++    let commit = reference.peel_to_commit().corrupt()?;+    let sha = commit.id().to_string();+    let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();+    let date = commit_date(&commit).unwrap_or_default();++    Ok((TagAnnotation { tagger, date: tag_date, message }, CommitInfo { sha, author, date }))+}++/// Loads all local branches from a repository, sorted with the default branch+/// first, then by most recent commit. Errors during iteration are silently skipped.+pub fn load_branches(repo: &gix::Repository) -> Vec<BranchInfo> {+    let refs = match repo.references() {+        Ok(r) => r,+        Err(_) => return Vec::new(),+    };+    let head_name = repo.head_name().ok().flatten();++    let mut branches: Vec<BranchInfo> = flatten_refs(refs.local_branches())+        .filter_map(|mut branch| {+            let commit = branch.peel_to_commit().ok()?;+            let date = commit_date(&commit)?;+            let timestamp = commit_timestamp(&commit)?;+            let name = branch.name().shorten().to_string();+            let is_default = head_name.as_ref().is_some_and(|h| h.as_ref() == branch.name());+            Some(BranchInfo { name, date, timestamp, is_default })+        })+        .collect();++    branches.sort_by(|a, b| {+        b.is_default.cmp(&a.is_default).then(b.timestamp.cmp(&a.timestamp))+    });+    branches+}++/// Loads a paginated log of commits reachable from the tip of a branch.+pub fn load_branch_log(+    repo: &gix::Repository,+    branch_name: &str,+    after: Option<String>,+    page_size: usize,+) -> CrateResult<BranchLog> {+    let ref_name = format!("refs/heads/{branch_name}");+    let tip = repo+        .find_reference(&ref_name)+        .map_err(|_| Error::NotFound(format!("branch {branch_name}")))?+        .peel_to_commit()+        .corrupt()?;++    let tip_sha = tip.id().to_string();++    let start_id = match after {+        Some(ref sha) => {+            let oid = gix::ObjectId::from_hex(sha.as_bytes())+                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;+            repo.find_object(oid)+                .map_err(|_| Error::NotFound(format!("commit {sha}")))?+                .id()+        }+        None => tip.id(),+    };++    let skip_count = if after.is_some() { 1 } else { 0 };++    let mut commits: Vec<CommitSummary> = start_id+        .ancestors()+        .all()+        .corrupt()?+        .skip(skip_count)+        .filter_map(|info| {+            let info = info.ok()?;+            let commit = info.id().object().ok()?.try_into_commit().ok()?;+            Some(CommitSummary {+                sha: info.id().to_string(),+                message: commit.message().ok()?.summary().to_string(),+                author: commit.author().ok()?.name.to_string(),+                date: commit_date(&commit)?,+            })+        })+        .take(page_size + 1)+        .collect();++    let has_next = commits.len() > page_size;+    if has_next {+        commits.pop();+    }++    let next_cursor = if has_next { commits.last().map(|c| c.sha.clone()) } else { None };++    Ok(BranchLog { tip_sha, commits, next_cursor })+}++/// Color palette for commit highlighting (matches commit.rs graph colors).+const COLORS: &[&str] = &[+    "var(--blue)", "var(--red)", "var(--green)", "var(--yellow)",+    "var(--violet)", "var(--magenta)", "var(--cyan)", "var(--orange)",+];++#[allow(clippy::cast_possible_truncation)]+fn commit_color(sha: &str) -> &'static str {+    let hash: u64 = sha.bytes().fold(0u64, |acc, b| {+        acc.wrapping_mul(31).wrapping_add(u64::from(b))+    });+    COLORS[hash as usize % COLORS.len()]+}++/// Loads blame annotations for a file at `{sha}:{path}`.+pub fn load_blame(repo: &gix::Repository, sha: &str, path: &str) -> CrateResult<Vec<BlameEntry>> {+    let oid = gix::ObjectId::from_hex(sha.as_bytes())+        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;++    let _commit = repo+        .find_object(oid)+        .map_err(|_| Error::NotFound(sha.to_string()))?+        .try_into_commit()+        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;++    let file_path = gix::bstr::BStr::new(path.as_bytes());+    let opts = gix::repository::blame_file::Options::default();+    let outcome = repo.blame_file(file_path, oid, opts).corrupt()?;++    let content = String::from_utf8_lossy(&outcome.blob).into_owned();+    let file_lines: Vec<&str> = content.split('\n').collect();++    let mut entries = Vec::new();+    for entry in &outcome.entries {+        let sha = entry.commit_id.to_string();+        let color = commit_color(&sha);+        let short_sha = sha[..8].to_string();+        for i in 0..entry.len.get() {+            let line_idx = (entry.start_in_blamed_file + i) as usize;+            let content = file_lines.get(line_idx).unwrap_or(&"").to_string();+            entries.push(BlameEntry {+                sha: sha.clone(),+                short_sha: short_sha.clone(),+                color: color.to_string(),+                num: line_idx + 1,+                content,+            });+        }+    }++    Ok(entries)+}++/// Loads up to `limit` commits starting from `after` (inclusive) or from+/// HEAD when `after` is `None`. Returns an empty vec if the repo has no commits.+pub fn load_commits(+    repo: &gix::Repository,+    after: Option<&str>,+    limit: usize,+) -> CrateResult<Vec<CommitEntry>> {+    let start_id = match after {+        Some(sha) => {+            let oid = gix::ObjectId::from_hex(sha.as_bytes())+                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;+            let obj = repo+                .find_object(oid)+                .map_err(|_| Error::NotFound(format!("commit {sha}")))?;+            obj.id()+        }+        None => match repo.head_commit() {+            Ok(head) => head.id(),+            Err(_) => return Ok(Vec::new()),+        },+    };++    let entries = start_id+        .ancestors()+        .all()+        .corrupt()?+        .filter_map(|info| {+            let info = info.ok()?;+            let commit = info.id().object().ok()?.try_into_commit().ok()?;+            Some(CommitEntry {+                sha: info.id().to_string(),+                message: commit.message().ok()?.summary().to_string(),+                date: commit_date(&commit).unwrap_or_default(),+                parents: commit.parent_ids().map(|id| id.to_string()).collect(),+            })+        })+        .take(limit)+        .collect();++    Ok(entries)~}
Msrc/lib.rs
~pub mod theme;~~// Re-exports for askama Template derives (resolved at `crate::`).-pub use config::favicon;~pub use config::{PKG_NAME, PKG_VERSION, base_url, hostname, ssh_prefix};
Msrc/main.rs
~use teak::config;~use teak::theme::{BASE_CSS, MARKDOWN_CSS};~use teak::{error, routes};+use tower_http::trace::TraceLayer;+use tracing_subscriber::EnvFilter;~+async fn shutdown_signal() {+    let ctrl_c = tokio::signal::ctrl_c();+    #[cfg(unix)]+    let sigterm = async {+        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())+            .expect("install SIGTERM handler")+            .recv()+            .await;+    };+    #[cfg(not(unix))]+    let sigterm = std::future::pending::<()>();++    tokio::select! {+        _ = ctrl_c => {},+        _ = sigterm => {},+    }+    tracing::info!("shutting down");+}+~#[tokio::main]~async fn main() -> error::Result<()> {+    tracing_subscriber::fmt()+        .with_env_filter(+            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),+        )+        .init();+~    let args = cli::Args::parse();~~    config::set_hostname(args.hostname);~    config::set_ssh_prefix(args.ssh_prefix);~    config::set_base_url(args.base_url);~-    let favicon_data = args.favicon.map_or_else(-        || include_bytes!("../static/favicon.svg").to_vec(),-        |path| std::fs::read(&path).unwrap_or_default(),-    );-    config::set_favicon(favicon_data);+    let (favicon_data, favicon_mime) = match args.favicon {+        Some(path) => match std::fs::read(&path) {+            Ok(data) => {+                let mime = match path.extension().and_then(|e| e.to_str()) {+                    Some("ico") => "image/x-icon",+                    Some("png") => "image/png",+                    Some("gif") => "image/gif",+                    _ => "image/svg+xml",+                };+                (data, mime)+            }+            Err(e) => {+                tracing::warn!("failed to read favicon {path:?}: {e}; using default");+                (include_bytes!("../static/favicon.svg").to_vec(), "image/svg+xml")+            }+        },+        None => (include_bytes!("../static/favicon.svg").to_vec(), "image/svg+xml"),+    };+    config::set_favicon(favicon_data, favicon_mime);~~    let theme_vars = match args.theme_file {~        Some(path) => std::fs::read_to_string(path)?,
~        root: args.root,~        css,~    };-    let router = routes::router(state);+    let router = routes::router(state).layer(TraceLayer::new_for_http());~    let addr = format!("{}:{}", args.bind, args.port);++    tracing::info!("listening on {addr}");~~    let socket = tokio::net::TcpListener::bind(&addr).await?;-    axum::serve(socket, router).await?;+    axum::serve(socket, router)+        .with_graceful_shutdown(shutdown_signal())+        .await?;~~    Ok(())~}
Msrc/routes.rs
~    )~}~-/// Serves the favicon (SVG or ICO bytes loaded at startup).+/// Catch-all for undefined sub-paths under `/{repo}/`.+/// Returns a styled 404 via the error handler instead of axum's bare 404.+async fn fallback(Path(params): Path<HashMap<String, String>>) -> impl IntoResponse {+    let repo = params.get("repo").map(|s| s.as_str()).unwrap_or("?");+    let path = params.get("path").map(|s| s.as_str()).unwrap_or("");+    Error::NotFound(format!("/{repo}/{path}"))+}++/// Serves the favicon bytes loaded at startup, with the correct MIME type.~async fn favicon() -> impl IntoResponse {~    (-        [(header::CONTENT_TYPE, "image/svg+xml")],+        [(header::CONTENT_TYPE, crate::config::favicon_mime_type())],~        crate::config::favicon(),~    )~}
~        .route("/{repo}/archive/{format}/{*ref}", get(archive::handler))~        .route("/{repo}/info/refs", get(git_upload::info_refs))~        .route("/{repo}/git-upload-pack", post(git_upload::upload_pack))+        .route("/{repo}/{*path}", get(fallback))~        .with_state(state)~}
Mtemplates/branch_detail.html
~  {% endfor %}~</div>~<nav class="pagination">-  {% if has_prev %}<a href="?page={{ page - 1 }}"><span class="pagination-sym">&lt;</span> <span>newer</span></a>{% else %}<span></span>{% endif %}-  {% if has_next %}<a href="?page={{ page + 1 }}"><span>older</span> <span class="pagination-sym">&gt;</span></a>{% else %}<span></span>{% endif %}+  {% if after.is_some() %}<a href="?"><span class="pagination-sym">&lt;</span> <span>newer</span></a>{% else %}<span></span>{% endif %}+  {% if let Some(cursor) = next_cursor %}<a href="?after={{ cursor }}"><span>older</span> <span class="pagination-sym">&gt;</span></a>{% else %}<span></span>{% endif %}~</nav>~{% endif %}~{% endblock %}
Mtemplates/commit_list.html
~  </div>~</div>~<nav class="pagination">-  {% if has_prev %}<a href="?page={{ page - 1 }}"><span class="pagination-sym">&lt;</span> <span>newer</span></a>{% else %}<span></span>{% endif %}-  {% if has_next %}<a href="?page={{ page + 1 }}"><span>older</span> <span class="pagination-sym">&gt;</span></a>{% else %}<span></span>{% endif %}+  {% if after.is_some() %}<a href="?"><span class="pagination-sym">&lt;</span> <span>newer</span></a>{% else %}<span></span>{% endif %}+  {% if let Some(cursor) = next_cursor %}<a href="?after={{ cursor }}"><span>older</span> <span class="pagination-sym">&gt;</span></a>{% else %}<span></span>{% endif %}~</nav>~{% endif %}~{% endblock %}
Mtemplates/error.html
~<body>~    <header>~        <ul>-            <li><a href="{{ base_url }}/">home</a></li>+            <li><a href="{{ base_url }}/">{{ crate::hostname() }}</a></li>~            <li>error</li>~        </ul>~    </header>
Msrc/routes/archive.rs
~        .arg(repo_path)~        .arg("archive")~        .arg(format!("--format={git_format}"))+        .arg("--")~        .arg(ref_)~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())+        .kill_on_drop(true)~        .spawn()~        .map_err(Error::Io)?;~
~        .arg(repo_path)~        .arg("archive")~        .arg("--format=tar")+        .arg("--")~        .arg(ref_)~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())+        .kill_on_drop(true)~        .spawn()~        .map_err(Error::Io)?;~
~        .stdin(Stdio::piped())~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())+        .kill_on_drop(true)~        .spawn()~        .map_err(Error::Io)?;~
~        if let Err(e) =~            tokio::io::copy(&mut tokio::io::BufReader::new(git_stdout), &mut xz_stdin).await~        {-            eprintln!("pipe git->xz: {e}");+            tracing::warn!(error = %e, "pipe git->xz");~        }~        let _ = xz_stdin.shutdown().await;~        // Reap git (its stdout is fully consumed now).
~    let _ = stderr.read_to_string(&mut buf).await;~    let ok = child.wait().await.is_ok_and(|s| s.success());~    if !ok || !buf.is_empty() {-        eprintln!("{label} failed: {buf}");+        tracing::warn!(label, stderr = %buf, "child process failed");~    }~}~+/// Replaces characters in `s` that are invalid in an HTTP quoted-string+/// (`"` and `\`) with `-` so that the value can safely appear in a+/// `Content-Disposition` filename parameter.+fn sanitize_filename_component(s: &str) -> String {+    s.replace(['"', '\\'], "-")+}+~/// Streams a repository archive for the given ref in the requested format.~pub async fn handler(~    State(state): State<AppState>,
~        .strip_prefix("refs/heads/")~        .or_else(|| params.ref_.strip_prefix("refs/tags/"))~        .unwrap_or(&params.ref_);-    let filename = format!("{name}-{short_ref}.{}", cfg.extension);++    // Replace characters that are invalid in HTTP quoted-string values (`"` and `\`)+    // so the Content-Disposition header is always well-formed.+    let filename = format!(+        "{}-{}.{}",+        sanitize_filename_component(&name),+        sanitize_filename_component(short_ref),+        cfg.extension+    );~    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))-        .map_err(|_| Error::BadRequest("invalid header value".into()))?;+        .map_err(|_| Error::BadRequest("invalid filename in archive response".into()))?;~~    let content_type = HeaderValue::from_static(cfg.content_type);~
Msrc/routes/blame.rs
~use axum::response::IntoResponse;~use serde::Deserialize;~-use crate::error::{Error, Result};-use crate::git::{self, GitResultExt as _};+use crate::error::Result;+use crate::git;~use crate::routes::{AppState, Breadcrumb, RepoName};~~/// Path parameters for the blame route.
~    path: String,~}~-/// A single annotated line in the blame view.-struct BlameLine {-    /// Line number (1-based).-    num: usize,-    /// Full commit SHA.-    sha: String,-    /// Short SHA (first 8 characters).-    short_sha: String,-    /// Color variable for the SHA column.-    color: String,-    /// The line content.-    content: String,-}-~/// Template data for the blame page.~#[derive(Template, WebTemplate)]~#[template(path = "blame.html")]
~    path: String,~    tree_url: String,~    breadcrumbs: Vec<Breadcrumb>,-    lines: Vec<BlameLine>,+    lines: Vec<git::BlameEntry>,~}~-/// Color palette for commit highlighting (matches commit.rs graph colors).-const COLORS: &[&str] = &[-    "var(--blue)",-    "var(--red)",-    "var(--green)",-    "var(--yellow)",-    "var(--violet)",-    "var(--magenta)",-    "var(--cyan)",-    "var(--orange)",-];--#[allow(clippy::cast_possible_truncation)]-fn commit_color(sha: &str) -> &'static str {-    let hash: u64 = sha.bytes().fold(0u64, |acc, b| {-        acc.wrapping_mul(31).wrapping_add(u64::from(b))-    });-    COLORS[hash as usize % COLORS.len()]-}-~/// Renders the blame view for a file.~pub async fn handler(~    State(state): State<AppState>,
~    git::validate_path(&params.path)?;~~    let git_repo = git::open_repo(&state.root, &name)?;--    let oid = gix::ObjectId::from_hex(params.sha.as_bytes())-        .map_err(|_| Error::BadRequest(format!("invalid sha: {}", params.sha)))?;+    let lines = git::load_blame(&git_repo, &params.sha, &params.path)?;~-    // Verify the commit exists.-    let _commit = git_repo-        .find_object(oid)-        .map_err(|_| Error::NotFound(params.sha.clone()))?-        .try_into_commit()-        .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?;--    let file_path = gix::bstr::BStr::new(params.path.as_bytes());--    let opts = gix::repository::blame_file::Options::default();-    let outcome = git_repo.blame_file(file_path, oid, opts).corrupt()?;--    // Split the blob into lines.-    let content = String::from_utf8_lossy(&outcome.blob).into_owned();-    let file_lines: Vec<&str> = content.split('\n').collect();--    // Build per-line blame entries.-    let mut lines: Vec<BlameLine> = Vec::new();-    for entry in &outcome.entries {-        let sha = entry.commit_id.to_string();-        let color = commit_color(&sha);-        let short_sha = sha[..8].to_string();--        for i in 0..entry.len.get() {-            let line_idx = (entry.start_in_blamed_file + i) as usize;-            let content = file_lines.get(line_idx).unwrap_or(&"").to_string();-            lines.push(BlameLine {-                num: line_idx + 1,-                sha: sha.clone(),-                short_sha: short_sha.clone(),-                color: color.to_string(),-                content,-            });-        }-    }-~    let b = crate::config::base_url();~    let tree_url = format!("{b}/{name}/commits/{}/tree/{}", params.sha, params.path);-~    let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, &params.sha, &params.path);~~    Ok(BlameView {
Msrc/routes/branch.rs
~use serde::Deserialize;~~use crate::error::Result;-use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::git;~use crate::routes::{AppState, RepoName};~~const PAGE_SIZE: usize = 100;
~#[derive(Deserialize)]~pub(super) struct Pagination {~    #[serde(default)]-    page: usize,+    after: Option<String>,~}~-/// Template context for a single branch in the list.-struct BranchSummary {-    name: String,-    date: String,-    timestamp: i64,-    is_default: bool,-}-~/// Template context for the branch list page.~#[derive(Template, WebTemplate)]~#[template(path = "branch_list.html")]~struct BranchList {~    repo: String,-    branches: Vec<BranchSummary>,-}--/// Template context for a single commit row in the log.-struct CommitSummary {-    sha: String,-    message: String,-    author: String,-    date: String,+    branches: Vec<git::BranchInfo>,~}~~/// Template context for the branch detail (log) page.
~    repo: String,~    branch: String,~    tip_sha: String,-    commits: Vec<CommitSummary>,-    page: usize,-    has_prev: bool,-    has_next: bool,+    commits: Vec<git::CommitSummary>,+    after: Option<String>,+    next_cursor: Option<String>,~}~~/// Renders the full list of branches for a repository.
~    RepoName(repo): RepoName,~) -> Result<impl IntoResponse> {~    let git_repo = git::open_repo(&state.root, &repo)?;--    let head_name = git_repo.head_name().ok().flatten();--    // `.local_branches()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.-    // The first `.flatten()` unwraps the outer `Result`, the second unwraps-    // each `Result<Reference>`, silently discarding any errors.-    let mut branches: Vec<BranchSummary> =-        git::flatten_refs(git_repo.references().corrupt()?.local_branches())-            .filter_map(|mut branch| {-                let commit = branch.peel_to_commit().ok()?;-                let date = commit_date(&commit)?;-                let timestamp = commit_timestamp(&commit)?;-                let name = branch.name().shorten().to_string();-                let is_default = head_name-                    .as_ref()-                    .is_some_and(|h| h.as_ref() == branch.name());--                Some(BranchSummary {-                    name,-                    date,-                    timestamp,-                    is_default,-                })-            })-            .collect();--    branches.sort_by(|a, b| {-        b.is_default-            .cmp(&a.is_default)-            .then(b.timestamp.cmp(&a.timestamp))-    });-+    let branches = git::load_branches(&git_repo);~    Ok(BranchList { repo, branches })~}~
~    Path(params): Path<BranchPath>,~    Query(pagination): Query<Pagination>,~) -> Result<impl IntoResponse> {-    let branch = params.branch;-    let page = pagination.page;-+    let after = pagination.after.filter(|s| !s.is_empty());~    let git_repo = git::open_repo(&state.root, &repo)?;--    let ref_name = format!("refs/heads/{branch}");-    let tip = git_repo-        .find_reference(&ref_name)-        .map_err(|_| crate::error::Error::NotFound(format!("branch {branch}")))?-        .peel_to_commit()-        .corrupt()?;--    let tip_sha = tip.id().to_string();--    // Fetch one extra commit to determine if a next page exists.-    let mut commits: Vec<CommitSummary> = tip-        .id()-        .ancestors()-        .all()-        .corrupt()?-        .filter_map(|info| {-            let info = info.ok()?;-            let commit = info.id().object().ok()?.try_into_commit().ok()?;--            let sha = info.id().to_string();-            let message = commit.message().ok()?.summary().to_string();-            let author = commit.author().ok()?.name.to_string();-            let date = commit_date(&commit)?;--            Some(CommitSummary {-                sha,-                message,-                author,-                date,-            })-        })-        .skip(page * PAGE_SIZE)-        .take(PAGE_SIZE + 1)-        .collect();--    // The extra commit was only fetched to detect whether a next page exists.-    // Discard it before rendering.-    let has_next = commits.len() > PAGE_SIZE;-    if has_next {-        commits.pop();-    }+    let log = git::load_branch_log(&git_repo, &params.branch, after.clone(), PAGE_SIZE)?;~~    Ok(BranchDetail {~        repo,-        branch,-        tip_sha,-        commits,-        page,-        has_prev: page > 0,-        has_next,+        branch: params.branch,+        tip_sha: log.tip_sha,+        commits: log.commits,+        after,+        next_cursor: log.next_cursor,~    })~}
Msrc/routes/commit.rs
~    "var(--orange)",~];~-/// A single commit entry in the commit log.-struct CommitEntry {-    sha: String,-    message: String,-    date: String,-    parents: Vec<String>,-}--/// Loads up to `limit` commits from `HEAD`, walking ancestors.-/// Returns an empty vec if the repository has no commits.-fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {-    let Ok(head) = git_repo.head_commit() else {-        return Ok(Vec::new());-    };--    let entries = head-        .id()-        .ancestors()-        .all()-        .corrupt()?-        .filter_map(|info| {-            let info = info.ok()?;-            let commit = info.id().object().ok()?.try_into_commit().ok()?;--            Some(CommitEntry {-                sha: info.id().to_string(),-                message: commit.message().ok()?.summary().to_string(),-                date: commit_date(&commit).unwrap_or_default(),-                parents: commit.parent_ids().map(|id| id.to_string()).collect(),-            })-        })-        .take(limit)-        .collect();--    Ok(entries)-}-~/// (row, column) positions for every commit in the current page, plus a~/// snapshot of active lanes entering the page from above so the SVG renderer~/// can draw continuation edges.
~/// parent occupies, freeing the previous column for other branches. Lane~/// re-use keeps the graph narrow.~#[allow(clippy::option_if_let_else)]-fn compute_layout(commits: &[CommitEntry], page_start: usize) -> Layout {+fn compute_layout(commits: &[git::CommitEntry], page_start: usize) -> Layout {~    let mut active: Vec<Option<&str>> = vec![];~    let mut positions = HashMap::new();~    let mut active_at_page_start = vec![];
~/// 3. Node circles and lane-highlight backgrounds on top.~#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]~fn render_page_svg(-    commits: &[CommitEntry],+    commits: &[git::CommitEntry],~    layout: &Layout,~    page_start: usize,~    page_end: usize,
~~    // Pass 2: edges originating from commits on this page.~    for commit in &commits[page_start..page_end] {-        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+        let &(abs_row, col) = layout+            .positions+            .get(&commit.sha)+            .expect("commit must have a layout position");~        let x1 = lx(col);~        let y1 = ry(abs_row);~        let c = COLORS[col % COLORS.len()];
~~    // Pass 3: circles on top.~    for commit in &commits[page_start..page_end] {-        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+        let &(abs_row, col) = layout+            .positions+            .get(&commit.sha)+            .expect("commit must have a layout position");~        let cx = lx(col);~        let cy = ry(abs_row);~        let c = COLORS[col % COLORS.len()];
~/// Query parameters for the commit log page.~#[derive(Deserialize, Default)]~pub struct PageQuery {-    /// Zero-based page index.+    /// Cursor commit SHA — show commits older than this one (exclusive).+    /// When absent, the log starts from HEAD.~    #[serde(default)]-    page: usize,+    after: Option<String>,~}~~/// A branch or tag ref pointing at a commit.
~    repo: String,~    graph_svg: String,~    commits: Vec<CommitRow>,-    page: usize,-    has_prev: bool,-    has_next: bool,+    /// Cursor used for this page (`None` means we started from HEAD).+    after: Option<String>,+    /// Cursor for the next page of older commits (`None` means no more pages).+    next_cursor: Option<String>,~}~~/// Renders a paginated commit log with an SVG ancestry graph.
~    Query(q): Query<PageQuery>,~) -> Result<impl IntoResponse> {~    let git_repo = git::open_repo(&state.root, &repo)?;++    let after = q.after.filter(|s| !s.is_empty());~-    let page_start = q.page * PAGE_SIZE;-    let page_end = page_start + PAGE_SIZE;+    // Load cursor commit (for graph context) + PAGE_SIZE visible + 1 overflow.+    // The cursor commit (index 0) provides incoming-edge context for the SVG+    // renderer; it is skipped below via `page_start`.+    let load_count = 1 + PAGE_SIZE + 1;+    let entries = git::load_commits(&git_repo, after.as_deref(), load_count)?;~-    let entries = load_commits(&git_repo, page_end + 1)?;-    let has_next = entries.len() > page_end;-    let visible_end = page_end.min(entries.len());+    let page_start = if after.is_some() { 1 } else { 0 };+    let visible_end = (page_start + PAGE_SIZE).min(entries.len());+    let has_next = visible_end > page_start && entries.len() > page_start + PAGE_SIZE;~~    let layout = compute_layout(&entries, page_start);~    let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);
~            refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),~        })~        .collect();++    let next_cursor = if has_next {+        // has_next is only true when visible_end > page_start, so this is safe.+        Some(entries[visible_end - 1].sha.clone())+    } else {+        None+    };~~    Ok(CommitList {~        repo,~        graph_svg,~        commits,-        page: q.page,-        has_prev: q.page > 0,-        has_next,+        after,+        next_cursor,~    })~}~
Msrc/routes/git_upload.rs
~//! Both handlers shell out to `git upload-pack --stateless-rpc`, following the~//! same streaming pattern as the archive handler.~+use std::io;~use std::process::Stdio;~+use async_compression::tokio::bufread::GzipDecoder;~use axum::body::Body;~use axum::extract::{Query, State};~use axum::http::HeaderMap;~use axum::http::header;~use axum::response::IntoResponse;+use futures::StreamExt;~use serde::Deserialize;+use tokio::io::BufReader;~use tokio::process::Command;-use tokio_util::io::ReaderStream;+use tokio_util::io::{ReaderStream, StreamReader};~~use crate::error::{Error, Result};~use crate::git;
~pub async fn info_refs(~    State(state): State<AppState>,~    RepoName(repo): RepoName,+    headers: HeaderMap,~    Query(query): Query<InfoRefsQuery>,~) -> Result<impl IntoResponse> {~    let _service = query
~    }~~    let _git_repo = git::open_repo(&state.root, &repo)?;++    let git_protocol = headers+        .get("git-protocol")+        .and_then(|v| v.to_str().ok())+        .map(|s| s.to_string());~-    let output = Command::new("git")-        .arg("upload-pack")+    let mut cmd = Command::new("git");+    cmd.arg("upload-pack")~        .arg("--stateless-rpc")~        .arg("--advertise-refs")-        .arg(repo_path.as_os_str())+        .arg(repo_path.as_os_str());+    if let Some(protocol) = &git_protocol {+        cmd.env("GIT_PROTOCOL", protocol);+    }+    let output = cmd~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())~        .output()
~    buf.extend_from_slice(&output.stdout);~~    Ok((-        [(-            header::CONTENT_TYPE,-            "application/x-git-upload-pack-advertisement",-        )],+        [+            (+                header::CONTENT_TYPE,+                "application/x-git-upload-pack-advertisement",+            ),+            (header::CACHE_CONTROL, "no-cache"),+        ],~        buf,~    ))~}
~/// Processes a pack-request body (wants / haves) and streams back the generated~/// pack file.  The response is a pkt-line stream with status/progress messages~/// in band 2 and pack data in band 1.+///+/// The request body is streamed through an optional gzip decoder directly into+/// the child process's stdin — no single buffer holds the entire payload.~pub async fn upload_pack(~    State(state): State<AppState>,~    RepoName(repo): RepoName,~    headers: HeaderMap,-    body: String,+    body: Body,~) -> Result<impl IntoResponse> {~    let content_type = headers~        .get(header::CONTENT_TYPE)
~        return Err(Error::BadRequest(~            "expected application/x-git-upload-pack-request".into(),~        ));+    }++    let git_protocol = headers+        .get("git-protocol")+        .and_then(|v| v.to_str().ok())+        .map(|s| s.to_string());++    let content_encoding = headers+        .get(header::CONTENT_ENCODING)+        .and_then(|v| v.to_str().ok())+        .map(|s| s.to_string());++    // Reject unsupported encodings before spawning child/spawning tasks.+    if let Some(ref enc) = content_encoding && enc != "gzip" {+        return Err(Error::BadRequest(format!(+            "unsupported content encoding: {enc}"+        )));~    }~~    let repo_path = state.root.join(&repo);
~~    let _git_repo = git::open_repo(&state.root, &repo)?;~-    let mut child = Command::new("git")-        .arg("upload-pack")+    let mut cmd = Command::new("git");+    cmd.arg("upload-pack")~        .arg("--stateless-rpc")-        .arg(repo_path.as_os_str())+        .arg(repo_path.as_os_str());+    if let Some(protocol) = &git_protocol {+        cmd.env("GIT_PROTOCOL", protocol);+    }+    let mut child = cmd~        .stdin(Stdio::piped())~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())+        .kill_on_drop(true)~        .spawn()~        .map_err(Error::Io)?;~
~    let stdout = child.stdout.take().expect("stdout configured");~    let stderr = child.stderr.take().expect("stderr configured");~+    // Pipe request body (optionally decompressed) into git's stdin.~    tokio::spawn(async move {~        use tokio::io::AsyncWriteExt;-        let _ = stdin.write_all(body.as_bytes()).await;++        let stream = body.into_data_stream();+        let mapped = stream.map(|r| r.map_err(io::Error::other));+        let mut body_reader = StreamReader::new(mapped);++        let result = if content_encoding.as_deref() == Some("gzip") {+            let mut decoder = GzipDecoder::new(BufReader::new(body_reader));+            tokio::io::copy(&mut decoder, &mut stdin).await+        } else {+            tokio::io::copy(&mut body_reader, &mut stdin).await+        };++        if let Err(e) = result {+            tracing::warn!(error = %e, "pipe body -> git stdin");+        }~        let _ = stdin.shutdown().await;~    });~
Msrc/routes/raw.rs
~~    let content_type = content_type_for(&params.path);~-    Ok(([(header::CONTENT_TYPE, content_type)], blob.data.clone()))+    Ok((+        [+            (header::CONTENT_TYPE, content_type),+            (header::CONTENT_SECURITY_POLICY, "sandbox"),+            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),+        ],+        blob.data.clone(),+    ))~}~~/// Returns a `Content-Type` string based on the file extension of `path`.
Msrc/routes/repo.rs
~//!~//! Covers two routes: the repository list (`/`) and the repository detail~//! page (`/{repo}`).  The detail handler opens the repository with `gix`,-//! collects branch and tag summaries by peeling each ref to its commit, and-//! reads the description from `.git/description`.  Refs that cannot be read-//! are silently skipped rather than treated as fatal errors.+//! collects branch and tag summaries via the service layer, reads the+//! description from `.git/description`, and renders the README as HTML.~~use askama::Template;~use askama_web::WebTemplate;~use axum::extract::State;~use axum::response::IntoResponse;~-use pulldown_cmark::{Event, Options, Parser, Tag, html};+use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};~~use crate::error::Result;-use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::git::{self, commit_date};~use crate::routes::{AppState, RepoName};~~/// Reads the repository description from `.git/description`, returning `None`
~        && !url.starts_with("data:")~}~-/// Template context for a single branch.-struct BranchSummary {-    name: String,-    date: String,-    timestamp: i64,-    /// Whether this branch is the repository's HEAD.-    is_default: bool,-}--/// Template context for a single tag.-struct TagSummary {-    name: String,-    date: String,-    timestamp: i64,-}-~/// Template context for the repository detail page.~#[derive(Template, WebTemplate)]~#[template(path = "repo.html")]~struct Repo {~    name: String,~    description: Option<String>,-    branches: Vec<BranchSummary>,-    tags: Vec<TagSummary>,+    branches: Vec<git::BranchInfo>,+    tags: Vec<git::TagInfo>,~    commit_count: usize,~    head_sha: Option<String>,~    default_branch: Option<String>,-    /// README rendered to HTML, or `None` if no README was found.~    readme: Option<String>,~}~
~/// tags, and description.  Returns [`Error::RepoNotFound`] if no repository~/// exists at the given name, or [`Error::GitCorrupt`] if the ref database~/// cannot be read.-#[allow(clippy::too_many_lines)]~pub async fn detail(~    State(state): State<AppState>,~    RepoName(name): RepoName,~) -> Result<impl IntoResponse> {~    let git_repo = git::open_repo(&state.root, &name)?;~-    let platform = git_repo.references().corrupt()?;--    let head_name = git_repo.head_name().ok().flatten();--    // `.tags()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.-    // The first `.flatten()` unwraps the outer `Result`, the second unwraps-    // each `Result<Reference>`, silently discarding any errors.-    let mut tags: Vec<TagSummary> = git::flatten_refs(platform.tags())-        .filter_map(|mut tag| {-            let commit = tag.peel_to_commit().ok()?;-            let date = commit_date(&commit)?;-            let timestamp = commit_timestamp(&commit)?;-            let name = tag.name().shorten().to_string();--            Some(TagSummary {-                name,-                date,-                timestamp,-            })-        })-        .collect();--    tags.sort_by_key(|b| std::cmp::Reverse(b.timestamp));--    // Same double-flatten pattern as tags above.-    let mut branches: Vec<BranchSummary> = git::flatten_refs(platform.local_branches())-        .filter_map(|mut branch| {-            let commit = branch.peel_to_commit().ok()?;-            let date = commit_date(&commit)?;-            let timestamp = commit_timestamp(&commit)?;-            let name = branch.name().shorten().to_string();-            let is_default = head_name-                .as_ref()-                .is_some_and(|h| h.as_ref() == branch.name());--            Some(BranchSummary {-                name,-                date,-                timestamp,-                is_default,-            })-        })-        .collect();--    branches.sort_by(|a, b| {-        b.is_default-            .cmp(&a.is_default)-            .then(b.timestamp.cmp(&a.timestamp))-    });-+    let branches = git::load_branches(&git_repo);+    let tags = git::load_tags(&git_repo);~    let description = repo_description(&git_repo);+    let commit_count = git::commit_count(&git_repo);~    let head_id = git_repo.head_id().ok();-    let commit_count = head_id-        .as_ref()-        .and_then(|id| id.ancestors().all().ok())-        .map_or(0, std::iter::Iterator::count);--    // Resolve HEAD to a SHA so we can build stable raw URLs for images.+~    let head_sha = head_id.map(|id| id.to_string());~-    // Try each candidate filename in order, stopping at the first one found.~    let readme = ["HEAD:README.md", "HEAD:README", "HEAD:readme.md"]~        .iter()~        .find_map(|&spec| {-            // Resolve the rev-spec to a blob, discarding any that don't exist-            // or aren't blobs (e.g. submodules).~            let blob = git_repo~                .rev_parse_single(spec)~                .ok()~                .and_then(|id| id.object().ok())~                .and_then(|obj| obj.try_into_blob().ok())?;~-            // Silently skip non-UTF-8 content.~            let text = std::str::from_utf8(&blob.data).ok()?;~-            // Rewrite relative image and link URLs to the raw file serving-            // route so that assets committed alongside the README work.~            let mut rendered = String::new();~            let mut opts = Options::empty();~            opts.insert(Options::ENABLE_TABLES);
~            opts.insert(Options::ENABLE_DEFINITION_LIST);~            opts.insert(Options::ENABLE_SUPERSCRIPT);~            opts.insert(Options::ENABLE_MATH);-            let events = Parser::new_ext(text, opts).map(|event| match event {+            let events = Parser::new_ext(text, opts)+                .filter(|event| {+                    !matches!(event,+                        Event::Html(_)+                        | Event::InlineHtml(_)+                        | Event::Start(Tag::HtmlBlock)+                        | Event::End(TagEnd::HtmlBlock)+                    )+                })+                .map(|event| match event {~                Event::Start(Tag::Image {~                    link_type,~                    dest_url,
~            Some(rendered)~        });~-    let default_branch = head_name.map(|h| h.shorten().to_string());+    let default_branch = git_repo.head_name().ok().flatten().map(|h| h.shorten().to_string());~~    Ok(Repo {~        name,
~            let git_repo = git::open_repo(&state.root, &name).ok()?;~            let description = repo_description(&git_repo);~+            let commit_count = git::commit_count(&git_repo);~            let head_id = git_repo.head_id().ok();--            let commit_count = head_id-                .as_ref()-                .and_then(|id| id.ancestors().all().ok())-                .map_or(0, std::iter::Iterator::count);~~            let last_commit = head_id~                .and_then(|id| id.object().ok())
Msrc/routes/tag.rs
~use askama_web::WebTemplate;~use axum::extract::{Path, State};~use axum::response::IntoResponse;-use gix::bstr::ByteSlice;~use serde::Deserialize;~-use crate::error::{Error, Result};-use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::error::Result;+use crate::git;~use crate::routes::{AppState, RepoName};~~#[derive(Deserialize)]
~    tag: String,~}~-/// Template context for a single tag in the list.-struct TagSummary {-    name: String,-    date: String,-    timestamp: i64,-}-~/// Template context for the tag list page.~#[derive(Template, WebTemplate)]~#[template(path = "tag_list.html")]~struct TagList {~    repo: String,-    tags: Vec<TagSummary>,+    tags: Vec<git::TagInfo>,~}~~/// Template context for the tag detail page.
~struct TagDetail {~    repo: String,~    tag: String,-    /// Tagger name and date for annotated tags; `None` for lightweight tags.~    tagger: Option<String>,~    tag_date: Option<String>,-    /// Annotation message for annotated tags; `None` for lightweight tags.~    message: Option<String>,~    commit_sha: String,~    commit_author: String,
~    RepoName(repo): RepoName,~) -> Result<impl IntoResponse> {~    let git_repo = git::open_repo(&state.root, &repo)?;--    // `.tags()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.-    // The first `.flatten()` unwraps the outer `Result`, the second unwraps-    // each `Result<Reference>`, silently discarding any errors.-    let mut tags: Vec<TagSummary> = git::flatten_refs(git_repo.references().corrupt()?.tags())-        .filter_map(|mut tag| {-            let commit = tag.peel_to_commit().ok()?;-            let date = commit_date(&commit)?;-            let timestamp = commit_timestamp(&commit)?;-            let name = tag.name().shorten().to_string();--            Some(TagSummary {-                name,-                date,-                timestamp,-            })-        })-        .collect();--    tags.sort_by_key(|b| std::cmp::Reverse(b.timestamp));-+    let tags = git::load_tags(&git_repo);~    Ok(TagList { repo, tags })~}~
~    RepoName(repo): RepoName,~    Path(params): Path<TagPath>,~) -> Result<impl IntoResponse> {-    let tag_name = params.tag;-~    let git_repo = git::open_repo(&state.root, &repo)?;--    let ref_name = format!("refs/tags/{tag_name}");-    let mut reference = git_repo-        .find_reference(&ref_name)-        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;--    // Extract annotation info if this is an annotated tag.-    let (tagger, tag_date, message) = reference-        .peel_to_tag()-        .ok()-        .and_then(|t| {-            let decoded = t.decode().ok()?;-            let sig = decoded.tagger().ok().flatten();-            let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());-            let tag_date = sig.as_ref().and_then(|s| {-                // Parse the raw "seconds offset" time string.-                let mut parts = s.time.splitn(2, ' ');-                let seconds: i64 = parts.next()?.parse().ok()?;-                let offset_str = parts.next().unwrap_or("+0000");-                let sign = if offset_str.starts_with('-') {-                    -1i32-                } else {-                    1-                };-                let hhmm = offset_str.trim_start_matches(['+', '-']);-                let h: i32 = hhmm.get(..2)?.parse().ok()?;-                let m: i32 = hhmm.get(2..4)?.parse().ok()?;-                let offset_secs = sign * (h * 3600 + m * 60);-                let zone =-                    jiff::tz::TimeZone::fixed(jiff::tz::Offset::from_seconds(offset_secs).ok()?);-                jiff::Timestamp::from_second(seconds)-                    .map(|ts| ts.to_zoned(zone).date().to_string())-                    .ok()-            });-            let message = decoded.message.to_str_lossy().trim().to_string();-            let message = if message.is_empty() {-                None-            } else {-                Some(message)-            };-            Some((tagger, tag_date, message))-        })-        .unwrap_or((None, None, None));--    let commit = reference.peel_to_commit().corrupt()?;--    let commit_sha = commit.id().to_string();-    let commit_author = commit-        .author()-        .map(|a| a.name.to_string())-        .unwrap_or_default();-    let commit_date = commit_date(&commit).unwrap_or_default();+    let (annotation, commit) = git::load_tag(&git_repo, &params.tag)?;~~    Ok(TagDetail {~        repo,-        tag: tag_name,-        tagger,-        tag_date,-        message,-        commit_sha,-        commit_author,-        commit_date,+        tag: params.tag,+        tagger: annotation.tagger,+        tag_date: annotation.date,+        message: annotation.message,+        commit_sha: commit.sha,+        commit_author: commit.author,+        commit_date: commit.date,~    })~}