e3bde1e6
- commit
- e3bde1e6a9141567d42a9c906e6f4c370909cc95
- parent
- author
- Christopher K. Schmitt <me@shmish.dev>
- date
- 2026-07-08
- committer
- Christopher K. Schmitt <me@shmish.dev>
- date
- 2026-07-08
- changes
- 21
- insertions
- 754
- deletions
- 66
Msrc/main.rs
~mod cli;~mod error;+mod filters;~mod routes;~mod theme;~
~ Some(path) => std::fs::read_to_string(path)?,~ None => args.theme.css().to_owned(),~ };- let css = format!("{BASE_CSS}\n{MARKDOWN_CSS}\n{theme_vars}");+ let css = format!("{theme_vars}\n{BASE_CSS}\n{MARKDOWN_CSS}");~~ let state = routes::AppState {~ root: args.root,
Msrc/routes.rs
~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};++/// 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()+}~~/// Axum extractor that pulls the `{repo}` path parameter and validates it~/// contains no path traversal components before passing it to handlers.
Mstatic/auto.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #ffffff;~ --highlight: #eeeeee;
~ --primary: #444444;~ --emphasized: #1a1a1a;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #d4a017;~ --orange: #d05a2b;
Mstatic/base.css
~~main > h1 { margin-bottom: .75rem; }~main > p { margin-top: 0; }-.repo-commit-count { color: var(--secondary); }++.repo-nav-item {+ display: flex;+ align-items: center;+ gap: 1rem;+ padding: 0 1rem;+ min-height: 50px;+ text-decoration: none;+ color: var(--primary);+ font-family: var(--font-mono);+ border-top: 1px solid var(--secondary);+ transition: background-color .3s ease-out;+}++.repo-nav-item + .repo-nav-item { border-top: 1px solid var(--highlight); }+.repo-nav-item:last-child { border-bottom: 1px solid var(--secondary); }+.repo-nav-item:active { background-color: var(--highlight); transition: none; }+.repo-nav-item:hover > span:first-of-type { text-decoration: underline; text-underline-offset: 3px; }++.repo-nav-item-meta {+ margin-left: auto;+ color: var(--secondary);+}++.repo-nav-item-arrow {+ color: var(--secondary);+}~main > article { padding: 0 1rem; }~~.repo-list {
~.repo-meta {~ display: flex;~ justify-content: space-between;- font-size: .9rem;~ color: var(--secondary);~}~-details {+.tag-info {+ display: grid;+ grid-template-columns: max-content 1fr;+ gap: .2rem 1.5rem;+ font-family: var(--font-mono);+ margin: 0 1rem 1rem;+}++.tag-info dt { color: var(--secondary); }+.tag-info dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0; }+.tag-info a { color: var(--primary); }++.tag-downloads {+ font-family: var(--font-mono);+ padding: 0 1rem;+}++.tag-message {+ font-family: var(--font-mono);+ font-size: 1rem;+ padding: 1rem;+ margin: 0 1rem 1rem;~ background: var(--highlight);+ border-radius: 6px;+ white-space: pre-wrap;+ color: var(--primary);+}++.branch-list, .tag-list { display: flex; flex-direction: column; }++.branch-row, .tag-row {+ display: flex;+ justify-content: space-between;+ align-items: baseline;+ padding: .75rem 1rem;+ text-decoration: none;+ color: inherit;+ font-family: var(--font-mono);+}++.branch-row:hover, .tag-row:hover { background: var(--highlight); }++.branch-name,+.tag-name {+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+ min-width: 0;+}+++.branch-default {+ color: var(--secondary);+ margin-right: 1rem;+}++.branch-date, .tag-date {+ display: flex;+ align-items: baseline;+ flex-shrink: 0;+ color: var(--secondary);+ padding-left: 1rem;+}++.commit-log {+ display: flex;+ flex-direction: column;+}++.commit-row {+ display: grid;+ grid-template-columns: 5rem 1fr;+ grid-template-rows: auto auto;+ gap: .15rem 1rem;+ padding: .75rem 1rem;+ text-decoration: none;+ color: inherit;+ font-family: var(--font-mono);+}++.commit-row:hover { background: var(--highlight); }++.commit-sha {+ font-family: var(--font-mono);+ color: var(--secondary);+ grid-row: 1;+}++.commit-message {+ color: var(--primary);+ grid-row: 1;+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+}++.commit-meta {+ display: flex;+ justify-content: space-between;+ color: var(--secondary);+ grid-column: 2;+ grid-row: 2;+ min-width: 0;+}++.commit-meta span:first-child {+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+ min-width: 0;+}++.commit-meta span:last-child {+ flex-shrink: 0;+ padding-left: 1rem;+}++.pagination {+ display: flex;+ justify-content: space-between;+ padding: .75rem 1rem;+}++.pagination a { color: var(--secondary); text-decoration: none; }+.pagination a:hover { color: var(--primary); }++details {+ border-top: 1px solid var(--highlight);~}~~details:first-of-type {~ border-top: 1px solid var(--secondary);+}++details:last-of-type {+ border-bottom: 1px solid var(--highlight);~}~~summary {~ cursor: pointer;~ display: flex;~ align-items: center;+ gap: 1rem;~ padding: 0 1rem;~ min-height: 50px;- font-family: var(--font-body);- font-weight: bold;-}--summary::before {- content: '+';~ font-family: var(--font-mono);- margin-right: .5rem;- color: var(--primary);+ background: var(--highlight);~}~-details[open] summary::before {- content: '−';+.summary-icon {+ width: 1rem;+ height: 1rem;+ flex-shrink: 0;~}+~~details[open] summary {~ color: var(--emphasized);~}~+summary::after {+ content: '[+]';+ color: var(--secondary);+}++summary:not(:has(span))::after {+ margin-left: auto;+}++details[open] summary::after {+ content: '[−]';+}+~~summary span {~ color: var(--secondary);
~ margin: 0;~}~+details ul li + li {+ border-top: 1px solid var(--highlight);+}+~details ul li {~ display: flex;~ align-items: center;
~ font-size: 1rem;~}~-details ul li a:hover,-details ul li button:hover {- background: var(--background);-}+details ul li a,+details ul li button { transition: background-color .3s ease-out; }~+details ul li a:active,+details ul li button:active { background-color: var(--highlight); transition: none; }++details ul li a:hover span:first-child,+details ul li button:hover span:first-child { text-decoration: underline; text-underline-offset: 3px; }+~/* First span grows to fill available space in both link and button rows */~details ul li a span:first-child,~details ul li button span:first-child {
~ flex-shrink: 0;~ color: var(--secondary);~}++/* Accent colors — specificity matches details ul li a span:first-child */+details ul li a span.branch-name,+.branch-name { color: var(--blue); }++details ul li a span.tag-name,+.tag-name { color: var(--violet); }
Mstatic/dark.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #1e1e1e;~ --highlight: #252526;
~ --primary: #d4d4d4;~ --emphasized: #ffffff;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #d4a017;~ --orange: #d05a2b;
Mstatic/light.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #ffffff;~ --highlight: #eeeeee;
~ --primary: #444444;~ --emphasized: #1a1a1a;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #d4a017;~ --orange: #d05a2b;
Mstatic/solarized_auto.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #fdf6e3;~ --highlight: #eee8d5;
~ --primary: #657b83;~ --emphasized: #586e75;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #b58900;~ --orange: #cb4b16;
Mstatic/solarized_dark.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #002b36;~ --highlight: #073642;
~ --primary: #839496;~ --emphasized: #93a1a1;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #b58900;~ --orange: #cb4b16;
Mstatic/solarized_light.css
+@import url('https://fonts.bunny.net/css?family=ibm-plex-sans:400,600|ibm-plex-mono:400');+~:root {~ --background: #fdf6e3;~ --highlight: #eee8d5;
~ --primary: #657b83;~ --emphasized: #586e75;~- --font-body: system-ui, sans-serif;- --font-mono: ui-monospace, monospace;- --font-heading: system-ui, sans-serif;+ --font-body: "IBM Plex Sans", sans-serif;+ --font-mono: "IBM Plex Mono", monospace;+ --font-heading: "IBM Plex Sans", sans-serif;~~ --yellow: #b58900;~ --orange: #cb4b16;
Mtemplates/base.html
~ <link rel="stylesheet" href="/style.css">~</head>~<body>+{% include "sprites.svg" %}~ <header>~ {% block breadcrumbs %}{% endblock %}~ </header>
Mtemplates/repo.html
~{% if let Some(description) = description %}~<p>{{ description }}</p>~{% endif %}-<p class="repo-commit-count">{{ commit_count }} {% if commit_count == 1 %}commit{% else %}commits{% endif %}</p>-~<details>- <summary>Clone</summary>+ <summary><svg class="summary-icon"><use href="#icon-copy"/></svg>Clone</summary>~ <ul>~ <li><button data-url="git@{{ crate::hostname() }}:{{ crate::ssh_prefix() }}{{ name }}.git"><span>git@{{ crate::hostname() }}:{{ crate::ssh_prefix() }}{{ name }}.git</span><span>SSH</span></button></li>~ <li><button data-url="https://{{ crate::hostname() }}/{{ name }}.git"><span>https://{{ crate::hostname() }}/{{ name }}.git</span><span>HTTPS</span></button></li>
~</details>~~<details>- <summary>Branches <span>{{ branches.len() }}</span></summary>+ <summary><svg class="summary-icon"><use href="#icon-branch"/></svg>Branches <span>[{{ branches.len() }}]</span></summary>~ <ul>~ {% for branch in branches.iter().take(5) %}- <li><a href="/{{ name }}/branches/{{ branch.name }}"><span>{{ branch.name }}</span>{% if branch.is_default %}<span>default</span>{% endif %}<span>{{ branch.date }}</span></a></li>+ <li><a href="/{{ name }}/branches/{{ branch.name }}"><span class="branch-name">{{ branch.name }}</span>{% if branch.is_default %}<span>default</span>{% endif %}<span>{{ branch.date }}</span></a></li>~ {% endfor %}~ {% if branches.len() > 5 %}~ <li><a href="/{{ name }}/branches">see all {{ branches.len() }} branches</a></li>
~</details>~~<details>- <summary>Tags <span>{{ tags.len() }}</span></summary>+ <summary><svg class="summary-icon"><use href="#icon-tag"/></svg>Tags <span>[{{ tags.len() }}]</span></summary>~ <ul>~ {% for tag in tags.iter().take(5) %}- <li><a href="/{{ name }}/tags/{{ tag.name }}"><span>{{ tag.name }}</span><span>{{ tag.date }}</span></a></li>+ <li><a href="/{{ name }}/tags/{{ tag.name }}"><span class="tag-name">{{ tag.name }}</span><span>{{ tag.date }}</span></a></li>~ {% endfor %}~ {% if tags.len() > 5 %}~ <li><a href="/{{ name }}/tags">see all {{ tags.len() }} tags</a></li>
~ {% endif %}~ </ul>~</details>++<div class="repo-nav">+ {% if let Some(sha) = head_sha %}+ <a href="/{{ name }}/commits/{{ sha }}/tree" class="repo-nav-item">+ <svg class="summary-icon"><use href="#icon-tree"/></svg>+ <span>Tree</span>+ <span class="repo-nav-item-meta">[{{ sha[..8] }}]</span>+ <span class="repo-nav-item-arrow">[>]</span>+ </a>+ {% endif %}+ <a href="/{{ name }}/commits" class="repo-nav-item">+ <svg class="summary-icon"><use href="#icon-commits"/></svg>+ <span>Commits</span>+ <span class="repo-nav-item-meta">[{{ commit_count }}]</span>+ <span class="repo-nav-item-arrow">[>]</span>+ </a>+</div>~~{% if let Some(readme) = readme %}~<article>
Mtemplates/repo_list.html
~ {% if let Some(desc) = &repo.description %}~ <span class="repo-desc">{{ desc }}</span>~ {% endif %}- <span class="repo-meta"><span>{{ repo.commit_count }} {% if repo.commit_count == 1 %}commit{% else %}commits{% endif %}</span>{% if let Some(date) = &repo.last_commit %}<span>{{ date }}</span>{% endif %}</span>+ <span class="repo-meta"><span>{{ repo.commit_count }} {{ repo.commit_count|pluralize("commit", "commits") }}</span>{% if let Some(date) = &repo.last_commit %}<span>{{ date }}</span>{% endif %}</span>~ </a>~ {% endfor %}~</div>
Msrc/routes/branch.rs
+//! Route handlers for branch pages.+//!+//! Covers two routes: the branch list (`/{repo}/branches`) and the branch+//! detail page (`/{repo}/branches/{*branch}`). The detail handler resolves+//! the branch ref to its tip commit and walks ancestors to produce a log view.++use askama::Template;+use askama_web::WebTemplate;+use axum::extract::{Path, Query, State};~use axum::response::IntoResponse;+use serde::Deserialize;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName, commit_date};++const PAGE_SIZE: usize = 100;++#[derive(Deserialize)]+pub(super) struct BranchPath {+ branch: String,+}++#[derive(Deserialize)]+pub(super) struct Pagination {+ #[serde(default)]+ page: usize,+}++/// Template context for a single branch in the list.+struct BranchSummary {+ name: String,+ date: String,+ 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,+}~-pub async fn list() -> impl IntoResponse {- "Branch list"+/// Template context for the branch detail (log) page.+#[derive(Template, WebTemplate)]+#[template(path = "branch_detail.html")]+struct BranchDetail {+ repo: String,+ branch: String,+ commits: Vec<CommitSummary>,+ page: usize,+ has_prev: bool,+ has_next: bool,~}++/// Renders the full list of branches for a repository.+pub async fn list(+ 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 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 branches = 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 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,+ is_default,+ })+ })+ .collect();++ Ok(BranchList { repo, branches })+}++/// Renders a paginated log of commits reachable from the tip of the given branch.+pub async fn detail(+ State(state): State<AppState>,+ RepoName(repo): RepoName,+ Path(params): Path<BranchPath>,+ Query(pagination): Query<Pagination>,+) -> Result<impl IntoResponse> {+ let branch = params.branch;+ let page = pagination.page;++ let git_repo =+ gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++ let ref_name = format!("refs/heads/{branch}");+ let tip = git_repo+ .find_reference(&ref_name)+ .map_err(|_| Error::NotFound(format!("branch {branch}")))?+ .peel_to_commit()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;++ // Fetch one extra commit to determine if a next page exists.+ let mut commits: Vec<CommitSummary> = tip+ .id()+ .ancestors()+ .all()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?+ .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();+ }~-pub async fn detail() -> impl IntoResponse {- "Branch detail"+ Ok(BranchDetail {+ repo,+ branch,+ commits,+ page,+ has_prev: page > 0,+ has_next,+ })~}
Msrc/routes/repo.rs
~use askama_web::WebTemplate;~use axum::extract::State;~use axum::response::IntoResponse;-use jiff::Timestamp;-use jiff::tz::{Offset, TimeZone};~~use crate::error::{Error, Result};-use crate::routes::{AppState, RepoName};+use crate::routes::{AppState, RepoName, commit_date};~~/// Reads the repository description from `.git/description`, returning `None`~/// if the file is missing, empty, or contains the default git placeholder text.
~ .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository"))~}~-/// 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.-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()-}-~/// Returns `true` if `url` is a relative path that should be rewritten to~/// point at the raw file serving route.~fn is_relative_url(url: &str) -> bool {
~ branches: Vec<BranchSummary>,~ tags: Vec<TagSummary>,~ commit_count: usize,+ head_sha: Option<String>,~ /// README rendered to HTML, or `None` if no README was found.~ readme: Option<String>,~}
~ branches,~ tags,~ commit_count,+ head_sha,~ readme,~ })~}
Msrc/routes/tag.rs
+//! Route handlers for tag pages.+//!+//! Covers two routes: the tag list (`/{repo}/tags`) and the tag detail page+//! (`/{repo}/tags/{*tag}`). The detail handler peels the tag ref to its+//! commit and presents a release-oriented view with the annotation message,+//! commit info, and archive download links.++use askama::Template;+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::routes::{AppState, RepoName, commit_date};++#[derive(Deserialize)]+pub(super) struct TagPath {+ tag: String,+}++/// Template context for a single tag in the list.+struct TagSummary {+ name: String,+ date: String,+}++/// Template context for the tag list page.+#[derive(Template, WebTemplate)]+#[template(path = "tag_list.html")]+struct TagList {+ repo: String,+ tags: Vec<TagSummary>,+}++/// Template context for the tag detail page.+#[derive(Template, WebTemplate)]+#[template(path = "tag_detail.html")]+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,+ commit_date: String,+}~-pub async fn list() -> impl IntoResponse {- "Tag list"+/// Renders the full list of tags for a repository, sorted newest first.+pub async fn list(+ 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()))?;++ // `.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()+ .filter_map(|mut tag| {+ let commit = tag.peel_to_commit().ok()?;+ let date = commit_date(&commit)?;+ let name = tag.name().shorten().to_string();++ Some(TagSummary { name, date })+ })+ .collect();++ tags.sort_by(|a, b| b.date.cmp(&a.date));++ Ok(TagList { repo, tags })~}++/// Renders the detail page for a single tag.+pub async fn detail(+ State(state): State<AppState>,+ RepoName(repo): RepoName,+ Path(params): Path<TagPath>,+) -> Result<impl IntoResponse> {+ let tag_name = params.tag;++ let git_repo =+ gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++ 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) = match reference.peel_to_tag().ok() {+ None => (None, None, None),+ Some(t) => match t.decode() {+ Err(_) => (None, None, None),+ Ok(decoded) => {+ 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)+ };+ (tagger, tag_date, message)+ }+ },+ };++ let commit = reference+ .peel_to_commit()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;++ 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();~-pub async fn detail() -> impl IntoResponse {- "Tag detail"+ Ok(TagDetail {+ repo,+ tag: tag_name,+ tagger,+ tag_date,+ message,+ commit_sha,+ commit_author,+ commit_date,+ })~}
Asrc/filters.rs
+//! Custom Askama filters.++/// Returns `singular` if `count == 1`, otherwise `plural`.+pub fn pluralize(count: &usize, singular: &str, plural: &str) -> askama::Result<String> {+ Ok(if *count == 1 {+ singular.to_string()+ } else {+ plural.to_string()+ })+}
Atemplates/branch_detail.html
+{% extends "base.html" %}++{% block title %}{{ branch }} — {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+ <li><a href="/">{{ crate::hostname() }}</a></li>+ <li><a href="/{{ repo }}">{{ repo }}</a></li>+ <li><a href="/{{ repo }}/branches">branches</a></li>+ <li>{{ branch }}</li>+</ul>+{% endblock %}++{% block content %}+<h1>{{ branch }}</h1>++{% if commits.is_empty() %}+<p>No commits.</p>+{% else %}+<div class="commit-log">+ {% for commit in commits %}+ <a href="/{{ repo }}/commits/{{ commit.sha }}" class="commit-row">+ <span class="commit-sha">{{ commit.sha[..8] }}</span>+ <span class="commit-message">{{ commit.message }}</span>+ <span class="commit-meta"><span>{{ commit.author }}</span><span>{{ commit.date }}</span></span>+ </a>+ {% endfor %}+</div>+<nav class="pagination">+ {% if has_prev %}<a href="?page={{ page - 1 }}">← newer</a>{% endif %}+ {% if has_next %}<a href="?page={{ page + 1 }}">older →</a>{% endif %}+</nav>+{% endif %}+{% endblock %}
Atemplates/branch_list.html
+{% extends "base.html" %}++{% block title %}branches — {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+ <li><a href="/">{{ crate::hostname() }}</a></li>+ <li><a href="/{{ repo }}">{{ repo }}</a></li>+ <li>branches</li>+</ul>+{% endblock %}++{% block content %}+<h1>branches</h1>++{% if branches.is_empty() %}+<p>No branches.</p>+{% else %}+<div class="branch-list">+ {% for branch in branches %}+ <a href="/{{ repo }}/branches/{{ branch.name }}" class="branch-row">+ <span class="branch-name">{{ branch.name }}</span>+ <span class="branch-date">{% if branch.is_default %}<span class="branch-default">default</span>{% endif %}{{ branch.date }}</span>+ </a>+ {% endfor %}+</div>+{% endif %}+{% endblock %}
Atemplates/sprites.svg
+<svg xmlns="http://www.w3.org/2000/svg" style="display:none">+ <symbol id="icon-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M15 6a9 9 0 0 0-9 9V3"/>+ <circle cx="18" cy="6" r="3"/>+ <circle cx="6" cy="18" r="3"/>+ </symbol>+ <symbol id="icon-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/>+ <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>+ </symbol>+ <symbol id="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>+ </symbol>+ <symbol id="icon-tree" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/>+ <path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/>+ <path d="M3 5a2 2 0 0 0 2 2h3"/>+ <path d="M3 3v13a2 2 0 0 0 2 2h3"/>+ </symbol>+ <symbol id="icon-commits" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <circle cx="12" cy="12" r="3"/>+ <line x1="3" x2="9" y1="12" y2="12"/>+ <line x1="15" x2="21" y1="12" y2="12"/>+ </symbol>+</svg>
Atemplates/tag_detail.html
+{% extends "base.html" %}++{% block title %}{{ tag }} — {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+ <li><a href="/">{{ crate::hostname() }}</a></li>+ <li><a href="/{{ repo }}">{{ repo }}</a></li>+ <li><a href="/{{ repo }}/tags">tags</a></li>+ <li>{{ tag }}</li>+</ul>+{% endblock %}++{% block content %}+<h1>{{ tag }}</h1>++{% if tagger.is_some() || tag_date.is_some() %}+<dl class="tag-info">+ {% if let Some(tagger) = tagger %}+ <dt>tagger</dt><dd>{{ tagger }}</dd>+ {% endif %}+ {% if let Some(date) = tag_date %}+ <dt>tagged</dt><dd>{{ date }}</dd>+ {% endif %}+</dl>+{% endif %}++<dl class="tag-info">+ <dt>commit</dt><dd><a href="/{{ repo }}/commits/{{ commit_sha }}">{{ commit_sha }}</a></dd>+ <dt>author</dt><dd>{{ commit_author }}</dd>+ <dt>date</dt><dd>{{ commit_date }}</dd>+</dl>++<p class="tag-downloads">+ [<a href="/{{ repo }}/archive/tar.gz/{{ commit_sha }}">tar.gz</a>]+ [<a href="/{{ repo }}/archive/tar.xz/{{ commit_sha }}">tar.xz</a>]+ [<a href="/{{ repo }}/archive/zip/{{ commit_sha }}">zip</a>]+</p>++{% if let Some(message) = message %}+<pre class="tag-message">{{ message }}</pre>+{% endif %}+{% endblock %}
Atemplates/tag_list.html
+{% extends "base.html" %}++{% block title %}tags — {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+ <li><a href="/">{{ crate::hostname() }}</a></li>+ <li><a href="/{{ repo }}">{{ repo }}</a></li>+ <li>tags</li>+</ul>+{% endblock %}++{% block content %}+<h1>tags</h1>++{% if tags.is_empty() %}+<p>No tags.</p>+{% else %}+<div class="tag-list">+ {% for tag in tags %}+ <a href="/{{ repo }}/tags/{{ tag.name }}" class="tag-row">+ <span class="tag-name">{{ tag.name }}</span>+ <span class="tag-date">{{ tag.date }}</span>+ </a>+ {% endfor %}+</div>+{% endif %}+{% endblock %}