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
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(¶ms.path)- .components()- .any(|c| !matches!(c, std::path::Component::Normal(_)))- {- return Err(Error::BadRequest("invalid path".to_string()));- }+ git::validate_path(¶ms.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, ¶ms.sha, ¶ms.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(¶ms.path)- .components()- .any(|c| !matches!(c, Component::Normal(_)))- {- return Err(Error::BadRequest("invalid path".to_string()));- }+ git::validate_path(¶ms.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(¶ms.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, ¶ms.sha, &resolved_path);+ let b = crate::config::base_url();+ let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, ¶ms.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};