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
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"><</span> <span>newer</span></a>{% else %}<span></span>{% endif %}- {% if has_next %}<a href="?page={{ page + 1 }}"><span>older</span> <span class="pagination-sym">></span></a>{% else %}<span></span>{% endif %}+ {% if after.is_some() %}<a href="?"><span class="pagination-sym"><</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">></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"><</span> <span>newer</span></a>{% else %}<span></span>{% endif %}- {% if has_next %}<a href="?page={{ page + 1 }}"><span>older</span> <span class="pagination-sym">></span></a>{% else %}<span></span>{% endif %}+ {% if after.is_some() %}<a href="?"><span class="pagination-sym"><</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">></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(¶ms.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(¶ms.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, ¶ms.sha, ¶ms.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, ¶ms.sha, ¶ms.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, ¶ms.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(¶ms.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, ¶ms.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,~ })~}