08a6b817

Archive
Tree [08a6b817] [>]
commit
08a6b8172b6214de68bb8df0864944294c067589
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
changes
18
insertions
384
deletions
357
Restructure into lib+bin crate and fix clippy warnings
MCargo.toml
~version = "0.1.0"~edition = "2024"~+[lib]+name = "temp_name"+path = "src/lib.rs"+~[dependencies]~askama = "0.15.6"~askama_web = { version = "0.15.2", features = ["axum-0.8"] }
Msrc/cli.rs
~~use clap::Parser;~-use crate::theme::Theme;+use temp_name::theme::Theme;~~#[derive(Parser)]~pub struct Args {
Msrc/error.rs
~impl IntoResponse for Error {~    fn into_response(self) -> Response {~        let (status, kind) = match &self {-            Error::RepoNotFound(_) | Error::NotFound(_) => (StatusCode::NOT_FOUND, "Not Found"),-            Error::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad Request"),-            Error::Io(_) | Error::GitCorrupt(_) => {+            Self::RepoNotFound(_) | Self::NotFound(_) => (StatusCode::NOT_FOUND, "Not Found"),+            Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad Request"),+            Self::Io(_) | Self::GitCorrupt(_) => {~                (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error")~            }~        };
~            base_url: crate::base_url().to_string(),~        };~-        match tpl.render() {-            Ok(html) => {-                ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()-            }-            Err(_) => (status, self.to_string()).into_response(),-        }+        tpl.render().map_or_else(+            |_| (status, self.to_string()).into_response(),+            |html| ([(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)]+#[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/main.rs
~mod cli;-mod error;-mod filters;-mod routes;-mod theme;~-use std::sync::OnceLock;-~use clap::Parser;-use tokio::net::TcpListener;--use crate::error::Result;-use crate::theme::{BASE_CSS, MARKDOWN_CSS};--/// Package name, sourced from `Cargo.toml` at compile time.-pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");--/// Package version, sourced from `Cargo.toml` at compile time.-pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");--/// Hostname used in breadcrumbs and clone URLs, set once from `--hostname`.-static HOSTNAME: OnceLock<String> = OnceLock::new();--/// SSH clone URL path prefix, set once from `--ssh-prefix`.-static SSH_PREFIX: OnceLock<String> = OnceLock::new();--/// Base URL path prefix for reverse proxy support, set once from `--base-url`.-static BASE_URL: OnceLock<String> = OnceLock::new();+use temp_name::config;+use temp_name::theme::{BASE_CSS, MARKDOWN_CSS};+use temp_name::{error, routes};~-/// Favicon bytes, loaded at startup from the default or a custom path.-static FAVICON: OnceLock<Vec<u8>> = OnceLock::new();--/// Returns the configured hostname, or `"index"` if none was provided.-pub fn hostname() -> &'static str {-    HOSTNAME.get().map_or("index", |s| s.as_str())-}--/// Returns the configured SSH prefix, or an empty string if none was provided.-pub fn ssh_prefix() -> &'static str {-    SSH_PREFIX.get().map_or("", |s| s.as_str())-}--/// Returns the configured base URL path prefix, or an empty string if none was-/// provided.-pub fn base_url() -> &'static str {-    BASE_URL.get().map_or("", |s| s.as_str())-}--/// Returns the favicon bytes loaded at startup.-pub fn favicon() -> &'static [u8] {-    FAVICON.get().map_or(&[], |v| v.as_slice())-}-~#[tokio::main]-async fn main() -> Result<()> {+async fn main() -> error::Result<()> {~    let args = cli::Args::parse();~-    if let Some(hostname) = args.hostname {-        let _ = HOSTNAME.set(hostname);-    }-    if let Some(ssh_prefix) = args.ssh_prefix {-        let _ = SSH_PREFIX.set(ssh_prefix);-    }-    if let Some(base_url) = args.base_url {-        let _ = BASE_URL.set(base_url);-    }+    config::set_hostname(args.hostname);+    config::set_ssh_prefix(args.ssh_prefix);+    config::set_base_url(args.base_url);~-    let favicon_data = match args.favicon {-        Some(path) => std::fs::read(&path).unwrap_or_default(),-        None => include_bytes!("../static/favicon.svg").to_vec(),-    };-    let _ = FAVICON.set(favicon_data);+    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);~-    // Concatenate base styles, markdown styles, and theme variables into a-    // single stylesheet served at /style.css.~    let theme_vars = match args.theme_file {~        Some(path) => std::fs::read_to_string(path)?,~        None => args.theme.css().to_owned(),
~        root: args.root,~        css,~    };-~    let router = routes::router(state);~    let addr = format!("{}:{}", args.bind, args.port);-    let socket = TcpListener::bind(&addr).await?;~+    let socket = tokio::net::TcpListener::bind(&addr).await?;~    axum::serve(socket, router).await?;~~    Ok(())
Msrc/routes.rs
~use std::collections::HashMap;~use std::path::{Component, PathBuf};~+use crate::error::{Error, Result};~use axum::Router;~use axum::extract::{FromRequestParts, Path, State};~use axum::http::header;~use axum::http::request::Parts;~use axum::response::IntoResponse;~use axum::routing::get;-use jiff::Timestamp;-use jiff::tz::{Offset, TimeZone};--use crate::error::{Error, Result};--/// Returns the commit's author timestamp as Unix seconds.-pub(super) fn commit_timestamp(commit: &gix::Commit) -> Option<i64> {-    commit.time().ok().map(|t| t.seconds)-}--/// Formats a commit's author date as a `YYYY-MM-DD` string in the commit's-/// local timezone, returning `None` if the time cannot be parsed.-pub(super) fn commit_date(commit: &gix::Commit) -> Option<String> {-    let time = commit.time().ok()?;-    let zone = TimeZone::fixed(Offset::from_seconds(time.offset).ok()?);--    Timestamp::from_second(time.seconds)-        .map(|ts| ts.to_zoned(zone).date().to_string())-        .ok()+use serde::Serialize;++/// A breadcrumb segment for the navigation bar.+#[derive(Serialize)]+pub struct Breadcrumb {+    pub label: String,+    pub url: String,~}~-/// Formats a commit's committer date as a `YYYY-MM-DD` string in the-/// commit's local timezone, returning `None` if the time cannot be parsed.-pub(super) fn committer_date(commit: &gix::Commit) -> Option<String> {-    let sig = commit.committer().ok()?;-    let time = sig.time().ok()?;-    let zone = TimeZone::fixed(Offset::from_seconds(time.offset).ok()?);--    Timestamp::from_second(time.seconds)-        .map(|ts| ts.to_zoned(zone).date().to_string())-        .ok()+/// Builds breadcrumbs for a tree path.+///+/// The root breadcrumb is always `"tree"`.  Each path segment adds a breadcrumb+/// linking to its corresponding subdirectory.+pub fn build_breadcrumbs(base: &str, repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {+    let mut crumbs = Vec::new();+    crumbs.push(Breadcrumb {+        label: "tree".to_string(),+        url: format!("{base}/{repo}/commits/{sha}/tree"),+    });+    if !path.is_empty() {+        let mut accumulated = String::new();+        for segment in path.split('/') {+            if !accumulated.is_empty() {+                accumulated.push('/');+            }+            accumulated.push_str(segment);+            crumbs.push(Breadcrumb {+                label: segment.to_string(),+                url: format!("{base}/{repo}/commits/{sha}/tree/{accumulated}"),+            });+        }+    }+    crumbs~}~~/// Axum extractor that pulls the `{repo}` path parameter and validates it
~            return Err(Error::BadRequest("invalid repository name".to_string()));~        }~-        Ok(RepoName(name))+        Ok(Self(name))~    }~}~
~~/// Serves the favicon (SVG or ICO bytes loaded at startup).~async fn favicon() -> impl IntoResponse {-    ([(header::CONTENT_TYPE, "image/svg+xml")], crate::favicon())+    (+        [(header::CONTENT_TYPE, "image/svg+xml")],+        crate::config::favicon(),+    )~}~~/// Builds the application router with all routes and shared state attached.
Msrc/theme.rs
~}~~impl Theme {-    pub fn css(&self) -> &'static str {+    pub const fn css(&self) -> &'static str {~        match self {-            Theme::Auto => include_str!("../static/auto.css"),-            Theme::Light => include_str!("../static/light.css"),-            Theme::Dark => include_str!("../static/dark.css"),-            Theme::SolarizedAuto => include_str!("../static/solarized_auto.css"),-            Theme::SolarizedLight => include_str!("../static/solarized_light.css"),-            Theme::SolarizedDark => include_str!("../static/solarized_dark.css"),+            Self::Auto => include_str!("../static/auto.css"),+            Self::Light => include_str!("../static/light.css"),+            Self::Dark => include_str!("../static/dark.css"),+            Self::SolarizedAuto => include_str!("../static/solarized_auto.css"),+            Self::SolarizedLight => include_str!("../static/solarized_light.css"),+            Self::SolarizedDark => include_str!("../static/solarized_dark.css"),~        }~    }~}
Msrc/routes/archive.rs
~use tokio::task::spawn_blocking;~~use crate::error::{Error, Result};+use crate::git;~use crate::routes::{AppState, RepoName};~~/// Path parameters for the archive route.
~    }~~    // Verify the ref resolves before shelling out.-    let git_repo = gix::open(&repo_path).map_err(|_| Error::RepoNotFound(name.clone()))?;+    let git_repo = git::open_repo(&state.root, &name)?;~    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {~        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));~    }
Msrc/routes/blame.rs
~use serde::Deserialize;~~use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName};+use crate::git::{self, GitResultExt as _};+use crate::routes::{AppState, Breadcrumb, RepoName};~~/// Path parameters for the blame route.~#[derive(Deserialize)]
~    content: String,~}~-/// A breadcrumb segment for the navigation bar.-struct Breadcrumb {-    label: String,-    url: String,-}-~/// Template data for the blame page.~#[derive(Template, WebTemplate)]~#[template(path = "blame.html")]
~    "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(b as u64));+    let hash: u64 = sha.bytes().fold(0u64, |acc, b| {+        acc.wrapping_mul(31).wrapping_add(u64::from(b))+    });~    COLORS[hash as usize % COLORS.len()]~}~
~    RepoName(name): RepoName,~    Path(params): Path<BlamePath>,~) -> Result<impl IntoResponse> {-    if std::path::Path::new(&params.path)-        .components()-        .any(|c| !matches!(c, std::path::Component::Normal(_)))-    {-        return Err(Error::BadRequest("invalid path".to_string()));-    }+    git::validate_path(&params.path)?;~-    let git_repo =-        gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;+    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 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)-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    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 b = crate::base_url();+    let b = crate::config::base_url();~    let tree_url = format!("{b}/{name}/commits/{}/tree/{}", params.sha, params.path);~-    let mut breadcrumbs = Vec::new();-    breadcrumbs.push(Breadcrumb {-        label: "tree".to_string(),-        url: format!("{b}/{name}/commits/{}/tree", params.sha),-    });-    if !params.path.is_empty() {-        let mut accumulated = String::new();-        for segment in params.path.split('/') {-            if !accumulated.is_empty() {-                accumulated.push('/');-            }-            accumulated.push_str(segment);-            breadcrumbs.push(Breadcrumb {-                label: segment.to_string(),-                url: format!("{b}/{name}/commits/{}/tree/{accumulated}", params.sha),-            });-        }-    }+    let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, &params.sha, &params.path);~~    Ok(BlameView {~        repo: name,
Msrc/routes/branch.rs
~use axum::response::IntoResponse;~use serde::Deserialize;~-use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName, commit_date, commit_timestamp};+use crate::error::Result;+use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::routes::{AppState, RepoName};~~const PAGE_SIZE: usize = 100;~
~    State(state): State<AppState>,~    RepoName(repo): RepoName,~) -> Result<impl IntoResponse> {-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    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_repo-        .references()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?-        .local_branches()-        .into_iter()-        .flatten()-        .flatten()-        .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,+    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();+            .collect();~~    branches.sort_by(|a, b| {~        b.is_default
~    let branch = params.branch;~    let page = pagination.page;~-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    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(|_| Error::NotFound(format!("branch {branch}")))?+        .map_err(|_| crate::error::Error::NotFound(format!("branch {branch}")))?~        .peel_to_commit()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+        .corrupt()?;~~    let tip_sha = tip.id().to_string();~
~        .id()~        .ancestors()~        .all()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .corrupt()?~        .filter_map(|info| {~            let info = info.ok()?;~            let commit = info.id().object().ok()?.try_into_commit().ok()?;
Msrc/routes/commit.rs
~//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`.~~use std::collections::HashMap;+use std::fmt::Write;~use std::ops::ControlFlow;~~use askama::Template;
~use serde::Deserialize;~~use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName, commit_date, committer_date};+use crate::git::{self, GitResultExt as _, commit_date, committer_date};+use crate::routes::{AppState, RepoName};~~use gix::diff::blob::UnifiedDiff;~use gix::diff::blob::platform::prepare_diff::Operation;
~/// 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 head = match git_repo.head_commit() {-        Ok(head) => head,-        Err(_) => return Ok(Vec::new()),+    let Ok(head) = git_repo.head_commit() else {+        return Ok(Vec::new());~    };~~    let entries = head~        .id()~        .ancestors()~        .all()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .corrupt()?~        .filter_map(|info| {~            let info = info.ok()?;~            let commit = info.id().object().ok()?.try_into_commit().ok()?;
~/// Uses a column-stealing algorithm: a commit claims the column its first~/// 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 {~    let mut active: Vec<Option<&str>> = vec![];~    let mut positions = HashMap::new();
~        if row == page_start {~            active_at_page_start = active~                .iter()-                .map(|opt| opt.map(|s| s.to_string()))+                .map(|opt| opt.map(std::string::ToString::to_string))~                .collect();~        }~~        let sha = commit.sha.as_str();~        let col = match active.iter().position(|s| *s == Some(sha)) {~            Some(i) => i,-            None => match active.iter().position(|s| s.is_none()) {-                Some(i) => {+            None => {+                if let Some(i) = active.iter().position(std::option::Option::is_none) {~                    active[i] = Some(sha);~                    i-                }-                None => {+                } else {~                    active.push(Some(sha));~                    active.len() - 1~                }-            },+            }~        };~~        positions.insert(commit.sha.clone(), (row, col));
~                    active.pop();~                }~                if active.iter().all(|s| *s != Some(first)) {-                    match active.iter().position(|s| s.is_none()) {+                    match active.iter().position(std::option::Option::is_none) {~                        Some(i) => active[i] = Some(first),~                        None => active.push(Some(first)),~                    }
~                if active.contains(&Some(parent.as_str())) {~                    continue;~                }-                match active.iter().position(|s| s.is_none()) {+                match active.iter().position(std::option::Option::is_none) {~                    Some(i) => active[i] = Some(parent),~                    None => active.push(Some(parent)),~                }
~    }~}~-/// Renders the SVG ancestry graph for commits[page_start..page_end].+/// Renders the SVG ancestry graph for commits[`page_start..page_end`].~///~/// Three passes:~/// 1. Continuation edges entering from above the viewport.
~///    same-column, L-shaped bends for lane changes, S-shaped curves for~///    merge arrows).~/// 3. Node circles and lane-highlight backgrounds on top.+#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]~fn render_page_svg(~    commits: &[CommitEntry],~    layout: &Layout,
~    let clamp_col = |c: usize| c.max(min_col).min(page_max_col);~~    let svg_h = (page_end - page_start) as f64 * ROW_H;-    let svg_w = (page_max_col - min_col + 1) as f64 * LANE_W + OUTER_R;--    let lx = |col: usize| -> f64 { (col - min_col) as f64 * LANE_W + OUTER_R };-    let ry =-        |abs_row: usize| -> f64 { (abs_row.saturating_sub(page_start)) as f64 * ROW_H + Y_MID };+    let svg_w = ((page_max_col - min_col + 1) as f64).mul_add(LANE_W, OUTER_R);++    let lx = |col: usize| -> f64 { ((col - min_col) as f64).mul_add(LANE_W, OUTER_R) };+    let ry = |abs_row: usize| -> f64 {+        ((abs_row.saturating_sub(page_start)) as f64).mul_add(ROW_H, Y_MID)+    };~~    let mut body = String::new();~
~            svg_h~        };~-        body.push_str(&format!(+        let _ = write!(+            body,~            r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#-        ));+        );~    }~~    // Pass 2: edges originating from commits on this page.
~                    p_col,~                ),~                None => {-                    body.push_str(&format!(+                    let _ = write!(+                        body,~                        r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#-                    ));+                    );~                    continue;~                }~            };~~            if col == p_col {-                body.push_str(&format!(+                let _ = write!(+                    body,~                    r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#-                ));+                );~            } else if idx == 0 {~                let r = BEND_R.min((x2 - x1).abs());~                let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };~-                body.push_str(&format!(+                let _ = write!(+                    body,~                    r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,~                    y2 - r,-                ));+                );~            } else {~                let pc = COLORS[p_col % COLORS.len()];~                let r = BEND_R.min((x2 - x1).abs());~                let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };~-                body.push_str(&format!(+                let _ = write!(+                    body,~                    r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,~                    y1 + r,-                ));+                );~            }~        }~    }
~        let row_top = cy - Y_MID;~        let rect_x = cx - OUTER_R;~-        body.push_str(&format!(+        let _ = write!(+            body,~            r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,~            top = row_top + OUTER_R,~            inner = OUTER_R,~            right = rect_x + OUTER_R,~            bot = row_top + ROW_H,~            bot_sub = row_top + ROW_H - OUTER_R,-        ));+        );~-        body.push_str(&format!(+        let _ = write!(+            body,~            r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#-        ));+        );~    }~~    format!(
~    RepoName(repo): RepoName,~    Query(q): Query<PageQuery>,~) -> Result<impl IntoResponse> {-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    let git_repo = git::open_repo(&state.root, &repo)?;~~    let page_start = q.page * PAGE_SIZE;~    let page_end = page_start + PAGE_SIZE;
~~    let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();~    if let Ok(refs) = git_repo.references() {-        for mut branch in refs.local_branches().into_iter().flatten().flatten() {+        for mut branch in git::flatten_refs(refs.local_branches()) {~            if let Ok(commit) = branch.peel_to_commit() {~                refs_by_sha~                    .entry(commit.id().to_string())
~            }~        }~-        for mut tag in refs.tags().into_iter().flatten().flatten() {+        for mut tag in git::flatten_refs(refs.tags()) {~            if let Ok(commit) = tag.peel_to_commit() {~                refs_by_sha~                    .entry(commit.id().to_string())
~            color: layout~                .positions~                .get(&e.sha)-                .map(|&(_, col)| COLORS[col % COLORS.len()])-                .unwrap_or("")+                .map_or("", |&(_, col)| COLORS[col % COLORS.len()])~                .to_string(),~            sha: e.sha.clone(),~            message: e.message.clone(),
~            match kind {~                DiffLineKind::Add => *self.file_added += 1,~                DiffLineKind::Remove => *self.file_removed += 1,-                _ => {}+                DiffLineKind::Context => {}~            }~~            hunk.push(DiffLine {
~}~~/// Displays a single commit: metadata, diff stat, and per-file inline diffs.+#[allow(clippy::too_many_lines)]~pub async fn detail(~    State(state): State<AppState>,~    RepoName(repo): RepoName,
~) -> Result<impl IntoResponse> {~    let sha = params.sha;~-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    let git_repo = git::open_repo(&state.root, &repo)?;~~    let oid = gix::ObjectId::from_hex(sha.as_bytes())~        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
~    // Collect refs (branches + tags) pointing to this commit.~    let mut refs: Vec<RefLabel> = Vec::new();~    if let Ok(rs) = git_repo.references() {-        for mut branch in rs.local_branches().into_iter().flatten().flatten() {+        for mut branch in git::flatten_refs(rs.local_branches()) {~            if let Ok(c) = branch.peel_to_commit()~                && c.id().to_string() == sha~            {
~            }~        }~-        for mut tag in rs.tags().into_iter().flatten().flatten() {+        for mut tag in git::flatten_refs(rs.tags()) {~            if let Ok(c) = tag.peel_to_commit()~                && c.id().to_string() == sha~            {
~    }~~    // Compute diff stat against the first parent (or empty tree for root commits).-    let commit_tree = commit-        .tree()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    let commit_tree = commit.tree().corrupt()?;~~    let parent_tree = commit~        .parent_ids()
~        .and_then(|o| o.try_into_commit().ok())~        .and_then(|c| c.tree().ok());~-    let mut resource_cache = git_repo-        .diff_resource_cache_for_tree_diff()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    let mut resource_cache = git_repo.diff_resource_cache_for_tree_diff().corrupt()?;~~    let mut files_changed = 0u64;~    let mut lines_added = 0u64;~    let mut lines_removed = 0u64;~    let mut file_changes: Vec<FileChange> = Vec::new();~+    #[allow(clippy::option_if_let_else)]~    let source_tree = match parent_tree {~        Some(ref t) => t,~        None => &git_repo.empty_tree(),
~~    source_tree~        .changes()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .corrupt()?~        .for_each_to_obtain_tree(&commit_tree, |change| {~            // Skip directory entries - only show leaf-level file changes.~            if change.entry_mode().is_tree() {
~~            Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))~        })-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+        .corrupt()?;~~    Ok(CommitDetail {~        repo,
Msrc/routes/raw.rs
~//! traversal, and serves the blob bytes with an appropriate `Content-Type`~//! header derived from the file extension.~-use std::path::Component;-~use axum::extract::{Path, State};~use axum::http::header;~use axum::response::IntoResponse;~use serde::Deserialize;~~use crate::error::{Error, Result};+use crate::git::{self, GitResultExt as _};~use crate::routes::{AppState, RepoName};~~/// Path parameters for the raw file route.
~    RepoName(name): RepoName,~    Path(params): Path<RawPath>,~) -> Result<impl IntoResponse> {-    // Reject paths containing non-normal components (e.g. `..`) to prevent-    // directory traversal.-    if std::path::Path::new(&params.path)-        .components()-        .any(|c| !matches!(c, Component::Normal(_)))-    {-        return Err(Error::BadRequest("invalid path".to_string()));-    }+    git::validate_path(&params.path)?;~-    let git_repo =-        gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;+    let git_repo = git::open_repo(&state.root, &name)?;~~    // Build a `sha:path` rev-spec and resolve it to a blob.~    let spec = format!("{}:{}", params.sha, params.path);
~        .rev_parse_single(spec.as_str())~        .map_err(|_| Error::NotFound(spec.clone()))?~        .object()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .corrupt()?~        .try_into_blob()~        .map_err(|_| Error::NotFound(spec))?;~~    let content_type = content_type_for(&params.path);~-    Ok(([(header::CONTENT_TYPE, content_type)], blob.data.to_vec()))+    Ok(([(header::CONTENT_TYPE, content_type)], blob.data.clone()))~}~~/// Returns a `Content-Type` string based on the file extension of `path`.
Msrc/routes/repo.rs
~use axum::extract::State;~use axum::response::IntoResponse;~-use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName, commit_date, commit_timestamp};+use pulldown_cmark::{Event, Options, Parser, Tag, html};~+use crate::error::Result;+use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::routes::{AppState, RepoName};+~/// Reads the repository description from `.git/description`, returning `None`~/// if the file is missing, empty, or contains the default git placeholder text.~fn repo_description(git_repo: &gix::Repository) -> 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 path = state.root.join(&name);-    let git_repo = gix::open(path).map_err(|_| Error::RepoNotFound(name.clone()))?;+    let git_repo = git::open_repo(&state.root, &name)?;~-    let platform = git_repo-        .references()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    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> = platform-        .tags()-        .into_iter()-        .flatten()-        .flatten()+    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)?;
~    tags.sort_by_key(|b| std::cmp::Reverse(b.timestamp));~~    // Same double-flatten pattern as tags above.-    let mut branches: Vec<BranchSummary> = platform-        .local_branches()-        .into_iter()-        .flatten()-        .flatten()+    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 commit_count = head_id~        .as_ref()~        .and_then(|id| id.ancestors().all().ok())-        .map(|walk| walk.count())-        .unwrap_or(0);+        .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());
~~            // Silently skip non-UTF-8 content.~            let text = std::str::from_utf8(&blob.data).ok()?;--            use pulldown_cmark::{Event, Options, Parser, Tag, html};~~            // Rewrite relative image and link URLs to the raw file serving~            // route so that assets committed alongside the README work.
~                    let dest_url = match &head_sha {~                        Some(sha) if is_relative_url(&dest_url) => format!(~                            "{}/{}/commits/{}/raw/{}",-                            crate::base_url(),+                            crate::config::base_url(),~                            name,~                            sha,~                            dest_url
~                    let dest_url = match &head_sha {~                        Some(sha) if is_relative_url(&dest_url) => format!(~                            "{}/{}/commits/{}/tree/{}",-                            crate::base_url(),+                            crate::config::base_url(),~                            name,~                            sha,~                            dest_url
~    let mut repos: Vec<RepoSummary> = std::fs::read_dir(&state.root)?~        .filter_map(|entry| {~            let name = entry.ok()?.file_name().to_string_lossy().into_owned();-            let git_repo = gix::open(state.root.join(&name)).ok()?;+            let git_repo = git::open_repo(&state.root, &name).ok()?;~            let description = repo_description(&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(|walk| walk.count())-                .unwrap_or(0);+                .map_or(0, std::iter::Iterator::count);~~            let last_commit = head_id~                .and_then(|id| id.object().ok())
Msrc/routes/tag.rs
~use serde::Deserialize;~~use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName, commit_date, commit_timestamp};+use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};+use crate::routes::{AppState, RepoName};~~#[derive(Deserialize)]~pub(super) struct TagPath {
~    State(state): State<AppState>,~    RepoName(repo): RepoName,~) -> Result<impl IntoResponse> {-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    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_repo-        .references()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?-        .tags()-        .into_iter()-        .flatten()-        .flatten()+    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)?;
~) -> Result<impl IntoResponse> {~    let tag_name = params.tag;~-    let git_repo =-        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;+    let git_repo = git::open_repo(&state.root, &repo)?;~~    let ref_name = format!("refs/tags/{tag_name}");~    let mut reference = git_repo
~        })~        .unwrap_or((None, None, None));~-    let commit = reference-        .peel_to_commit()-        .map_err(|e| Error::GitCorrupt(e.to_string()))?;+    let commit = reference.peel_to_commit().corrupt()?;~~    let commit_sha = commit.id().to_string();~    let commit_author = commit
Msrc/routes/tree.rs
~//! given tree object, with file entries syntax-highlighted inline and linked to~//! the raw handler.  Sub-directory entries are linked to deeper tree pages.~-use std::path::Component;+use std::fmt::Write;~use std::sync::LazyLock;~~use askama::Template;
~use syntect::parsing::SyntaxSet;~~use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName};+use crate::git::{self, GitResultExt as _};+use crate::routes::{AppState, Breadcrumb, RepoName};~~/// Syntax definitions loaded once and reused for every file view.~static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
~}~~/// Formats a byte count as a human-readable string.+#[allow(clippy::cast_precision_loss)]~fn format_size(bytes: u64) -> String {~    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];~    let mut size = bytes as f64;
~    }~}~-/// A breadcrumb segment for the navigation bar.-struct Breadcrumb {-    label: String,-    url: String,-}-~/// Template context for the tree/file page.~#[derive(Template, WebTemplate)]~#[template(path = "tree.html")]
~}~~/// Renders a tree or file at `{sha}:{path}` in the given repository.+#[allow(clippy::too_many_lines)]~pub async fn handler(~    State(state): State<AppState>,~    RepoName(name): RepoName,
~) -> Result<impl IntoResponse> {~    let path = params.path.unwrap_or_default();~-    // Reject paths containing non-normal components (e.g. `..`) to prevent-    // directory traversal.-    if !path.is_empty()-        && std::path::Path::new(&path)-            .components()-            .any(|c| !matches!(c, Component::Normal(_)))-    {-        return Err(Error::BadRequest("invalid path".to_string()));+    if !path.is_empty() {+        git::validate_path(&path)?;~    }~-    let git_repo =-        gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;+    let git_repo = git::open_repo(&state.root, &name)?;~~    let (object, resolved_path) = if path.is_empty() {~        // When path is empty, resolve the sha to a commit and get its tree.
~            .map_err(|_| Error::NotFound(params.sha.clone()))?~            .try_into_commit()~            .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?;-        let tree_id = commit-            .tree_id()-            .map_err(|e| Error::GitCorrupt(e.to_string()))?;-        let tree = git_repo-            .find_object(tree_id)-            .map_err(|e| Error::GitCorrupt(e.to_string()))?;+        let tree_id = commit.tree_id().corrupt()?;+        let tree = git_repo.find_object(tree_id).corrupt()?;~        (tree, String::new())~    } else {~        let spec = format!("{}:{}", params.sha, path);
~            .rev_parse_single(spec.as_str())~            .map_err(|_| Error::NotFound(spec.clone()))?~            .object()-            .map_err(|e| Error::GitCorrupt(e.to_string()))?;-        (obj, path.clone())+            .corrupt()?;+        (obj, path)~    };~-    let b = crate::base_url();-    let breadcrumbs = build_breadcrumbs(b, &name, &params.sha, &resolved_path);+    let b = crate::config::base_url();+    let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, &params.sha, &resolved_path);~    let raw_url = if resolved_path.is_empty() {~        String::new()~    } else {
~    let parent_url = if resolved_path.is_empty() {~        String::new()~    } else {-        let parent_path = resolved_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");+        let parent_path = resolved_path.rsplit_once('/').map_or("", |(p, _)| p);~        if parent_path.is_empty() {~            format!("{b}/{name}/commits/{}/tree", params.sha)~        } else {
~~            // Sort: directories first, then files, alphabetically within each group.~            entries.sort_by(|a, b| {-                if a.is_dir != b.is_dir {-                    b.is_dir.cmp(&a.is_dir)-                } else {+                if a.is_dir == b.is_dir {~                    a.name.cmp(&b.name)+                } else {+                    b.is_dir.cmp(&a.is_dir)~                }~            });~
~            let mut line_num = 1;~            for segment in inner.split_inclusive('\n') {~                if segment.ends_with('\n') {-                    numbered.push_str(&format!(+                    let _ = write!(+                        numbered,~                        "<span class=\"st-linenum\">{line_num}</span>{segment}"-                    ));+                    );~                    line_num += 1;~                } else {~                    numbered.push_str(segment);
~                format!("{}:{}", params.sha, resolved_path)~            };~            Err(Error::NotFound(spec))-        }-    }-}--/// Builds breadcrumbs for a tree path.-///-/// The root breadcrumb is always "tree".  Each path segment adds a breadcrumb-/// linking to its corresponding subdirectory.-fn build_breadcrumbs(base: &str, repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {-    let mut crumbs = Vec::new();-    crumbs.push(Breadcrumb {-        label: "tree".to_string(),-        url: format!("{base}/{repo}/commits/{sha}/tree"),-    });-    if !path.is_empty() {-        let mut accumulated = String::new();-        for segment in path.split('/') {-            if !accumulated.is_empty() {-                accumulated.push('/');-            }-            accumulated.push_str(segment);-            crumbs.push(Breadcrumb {-                label: segment.to_string(),-                url: format!("{base}/{repo}/commits/{sha}/tree/{accumulated}"),-            });~        }~    }-    crumbs~}
Asrc/config.rs
+//! Global application configuration set once at startup.+//!+//! Values are stored in `OnceLock` statics and accessed via the corresponding+//! getter functions.  All setters accept `Option` and silently skip `None`.++use std::sync::OnceLock;++pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");+pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");++static HOSTNAME: OnceLock<String> = OnceLock::new();+static SSH_PREFIX: OnceLock<String> = OnceLock::new();+static BASE_URL: OnceLock<String> = OnceLock::new();+static FAVICON: OnceLock<Vec<u8>> = OnceLock::new();++/// Returns the configured hostname, or `"index"` if none was provided.+pub fn hostname() -> &'static str {+    HOSTNAME.get().map_or("index", |s| s.as_str())+}++/// Returns the configured SSH prefix, or an empty string if none was provided.+pub fn ssh_prefix() -> &'static str {+    SSH_PREFIX.get().map_or("", |s| s.as_str())+}++/// Returns the configured base URL path prefix, or an empty string if none was+/// provided.+pub fn base_url() -> &'static str {+    BASE_URL.get().map_or("", |s| s.as_str())+}++/// Returns the favicon bytes loaded at startup.+pub fn favicon() -> &'static [u8] {+    FAVICON.get().map_or(&[], |v| v.as_slice())+}++pub fn set_hostname(v: Option<String>) {+    if let Some(v) = v {+        let _ = HOSTNAME.set(v);+    }+}++pub fn set_ssh_prefix(v: Option<String>) {+    if let Some(v) = v {+        let _ = SSH_PREFIX.set(v);+    }+}++pub fn set_base_url(v: Option<String>) {+    if let Some(v) = v {+        let _ = BASE_URL.set(v);+    }+}++pub fn set_favicon(v: Vec<u8>) {+    let _ = FAVICON.set(v);+}
Asrc/git.rs
+//! Git repository helpers used across route handlers.+//!+//! Provides convenience functions for common `gix` operations such as opening a+//! repository, formatting commit dates, validating paths, and iterating+//! references.  The [`GitResultExt`] extension trait lets handlers convert gix+//! errors into [`Error::GitCorrupt`] with a single `.corrupt()` call.++use std::path::Path;++use jiff::Timestamp;+use jiff::tz::{Offset, TimeZone};++use crate::error::{Error, Result as CrateResult};++/// 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()))+}++/// Rejects paths containing non-normal [`Component`]s (e.g. `..`) to prevent+/// directory traversal.+pub fn validate_path(path: &str) -> CrateResult<()> {+    if std::path::Path::new(path)+        .components()+        .any(|c| !matches!(c, std::path::Component::Normal(_)))+    {+        return Err(Error::BadRequest("invalid path".to_string()));+    }+    Ok(())+}++/// Returns the commit's author timestamp as Unix seconds.+pub fn commit_timestamp(commit: &gix::Commit) -> Option<i64> {+    commit.time().ok().map(|t| t.seconds)+}++/// Formats a commit's author date as a `YYYY-MM-DD` string in the commit's+/// local timezone, returning `None` if the time cannot be parsed.+pub fn commit_date(commit: &gix::Commit) -> Option<String> {+    let time = commit.time().ok()?;+    format_time(time.seconds, time.offset)+}++/// Formats a commit's committer date as a `YYYY-MM-DD` string in the+/// commit's local timezone, returning `None` if the time cannot be parsed.+pub fn committer_date(commit: &gix::Commit) -> Option<String> {+    let sig = commit.committer().ok()?;+    let time = sig.time().ok()?;+    format_time(time.seconds, time.offset)+}++/// Converts a Unix timestamp + timezone offset into a `YYYY-MM-DD` string.+fn format_time(seconds: i64, offset: i32) -> Option<String> {+    let zone = TimeZone::fixed(Offset::from_seconds(offset).ok()?);+    Timestamp::from_second(seconds)+        .map(|ts| ts.to_zoned(zone).date().to_string())+        .ok()+}++/// Extension trait adding `.corrupt()` to `Result<T, E>` where `E: Display`.+///+/// Converts an error into [`Error::GitCorrupt`] by calling `.to_string()` on+/// the inner error value.  This removes the need for repetitive+/// `.map_err(|e| Error::GitCorrupt(e.to_string()))` chains.+///+/// # Usage+///+/// ```ignore+/// use crate::git::GitResultExt as _;+/// let repo = git_repo.references().corrupt()?;+/// ```+pub trait GitResultExt<T> {+    fn corrupt(self) -> CrateResult<T>;+}++impl<T, E: std::fmt::Display> GitResultExt<T> for std::result::Result<T, E> {+    fn corrupt(self) -> CrateResult<T> {+        self.map_err(|e| Error::GitCorrupt(e.to_string()))+    }+}++/// Silently flattens a `Result<impl IntoIterator<Item = Result<T, E2>>, E1>` into+/// an `Iterator<Item = T>`.+///+/// Both the outer and inner `Result` layers are consumed via `.into_iter()` and+/// `.flatten()`, so errors at either level are silently skipped.  This matches+/// the common git-ref iteration pattern where a single corrupt ref should not+/// crash the page.+pub fn flatten_refs<T, E1, E2, I>(result: std::result::Result<I, E1>) -> impl Iterator<Item = T>+where+    I: IntoIterator<Item = std::result::Result<T, E2>>,+{+    result.into_iter().flatten().flatten()+}
Asrc/lib.rs
+//! Web-based git repository browser built on `gix` and `axum`.+//!+//! This library crate provides all application logic: configuration, error+//! types, git operations, route handlers, and template rendering.  The binary+//! crate (`main.rs`) parses CLI arguments and orchestrates startup.++pub mod config;+pub mod error;+pub mod filters;+pub mod git;+pub mod routes;+pub mod theme;++// Re-export so askama Template derives can find them at `crate::`.+pub use config::favicon;+pub use config::{PKG_NAME, PKG_VERSION, base_url, hostname, ssh_prefix};