e3bde1e6

Archive
Tree [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
Add branch and tag views
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">[&gt;]</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">[&gt;]</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 %}