🏡 index : ~doyle/rgit.git

author Jordan Doyle <jordan@doyle.la> 2023-12-31 1:57:53.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-12-31 2:22:36.0 +00:00:00
commit
39bdc18d9982b21db2ca64e6173d495d37e793ed [patch]
tree
26d1a11dfdbb75eff41a7f56cfc400c0f4eee3ac
parent
33341458409fe83f161c0dd274f4ed9279f07140
download
39bdc18d9982b21db2ca64e6173d495d37e793ed.tar.gz

Retain references across pages



Diff

 Cargo.toml                      |  2 +-
 src/database/indexer.rs         | 11 +++++++---
 src/git.rs                      | 48 ++++++++++++++++++++++++++----------------
 src/main.rs                     |  3 +--
 src/methods/repo/about.rs       | 22 ++++++++++++++++---
 src/methods/repo/commit.rs      | 11 ++++++++--
 src/methods/repo/diff.rs        | 11 +++++++---
 src/methods/repo/log.rs         | 19 +++++++++++++----
 src/methods/repo/refs.rs        |  4 +++-
 src/methods/repo/smart_git.rs   |  6 +++--
 src/methods/repo/summary.rs     |  4 +++-
 src/methods/repo/tag.rs         | 13 +++++++----
 src/methods/repo/tree.rs        | 35 ++++++++++++++++++++-----------
 src/syntax_highlight.rs         |  3 +--
 templates/repo/base.html        | 11 +++++-----
 templates/repo/commit.html      |  7 +++---
 templates/repo/file.html        |  3 ++-
 templates/repo/log.html         |  7 +-----
 templates/repo/macros/link.html |  3 +++-
 templates/repo/tag.html         |  3 ++-
 20 files changed, 153 insertions(+), 73 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 31ecc41..37aabed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,7 +29,7 @@ moka = { version = "0.12.0", features = ["future"] }
once_cell = "1.18"
path-clean = "1.0.1"
parking_lot = "0.12"
serde = { version = "1.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive", "rc"] }
sha2 = "0.10"
syntect = "5"
sled = { version = "0.34", features = ["compression"] }
diff --git a/src/database/indexer.rs b/src/database/indexer.rs
index f66dbb8..11ba789 100644
--- a/src/database/indexer.rs
+++ b/src/database/indexer.rs
@@ -7,7 +7,7 @@ use std::{
use git2::Sort;
use ini::Ini;
use time::OffsetDateTime;
use tracing::{info, info_span};
use tracing::{error, info, info_span};

use crate::database::schema::{
    commit::Commit,
@@ -89,7 +89,9 @@ fn update_repository_reflog(scan_path: &Path, db: &sled::Db) {
            let reference = reference.unwrap();

            let reference_name = String::from_utf8_lossy(reference.name_bytes());
            if !reference_name.starts_with("refs/heads/") {
            if !reference_name.starts_with("refs/heads/")
                && !reference_name.starts_with("refs/tags/")
            {
                continue;
            }

@@ -119,7 +121,10 @@ fn update_repository_reflog(scan_path: &Path, db: &sled::Db) {
            // TODO: only scan revs from the last time we looked
            let mut revwalk = git_repository.revwalk().unwrap();
            revwalk.set_sorting(Sort::REVERSE).unwrap();
            revwalk.push_ref(&reference_name).unwrap();
            if let Err(error) = revwalk.push_ref(&reference_name) {
                error!(%error, "Failed to revwalk reference");
                continue;
            }

            let mut i = 0;
            for rev in revwalk {
diff --git a/src/git.rs b/src/git.rs
index e9699d9..bbb1263 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -11,8 +11,8 @@ use anyhow::{Context, Result};
use bytes::{Bytes, BytesMut};
use comrak::{ComrakOptions, ComrakPlugins};
use git2::{
    BranchType, DiffFormat, DiffLineType, DiffOptions, DiffStatsFormat, Email, EmailCreateOptions,
    ObjectType, Oid, Signature,
    DiffFormat, DiffLineType, DiffOptions, DiffStatsFormat, Email, EmailCreateOptions, ObjectType,
    Oid, Signature,
};
use moka::future::Cache;
use parking_lot::Mutex;
@@ -51,7 +51,11 @@ impl Git {

impl Git {
    #[instrument(skip(self))]
    pub async fn repo(self: Arc<Self>, repo_path: PathBuf) -> Result<Arc<OpenRepository>> {
    pub async fn repo(
        self: Arc<Self>,
        repo_path: PathBuf,
        branch: Option<Arc<str>>,
    ) -> Result<Arc<OpenRepository>> {
        let repo = tokio::task::spawn_blocking({
            let repo_path = repo_path.clone();
            move || git2::Repository::open(repo_path)
@@ -64,6 +68,7 @@ impl Git {
            git: self,
            cache_key: repo_path,
            repo: Mutex::new(repo),
            branch,
        }))
    }
}
@@ -72,6 +77,7 @@ pub struct OpenRepository {
    git: Arc<Git>,
    cache_key: PathBuf,
    repo: Mutex<git2::Repository>,
    branch: Option<Arc<str>>,
}

impl OpenRepository {
@@ -79,7 +85,6 @@ impl OpenRepository {
        self: Arc<Self>,
        path: Option<PathBuf>,
        tree_id: Option<&str>,
        branch: Option<String>,
        formatted: bool,
    ) -> Result<PathDestination> {
        let tree_id = tree_id
@@ -93,12 +98,11 @@ impl OpenRepository {
            let mut tree = if let Some(tree_id) = tree_id {
                repo.find_tree(tree_id)
                    .context("Couldn't find tree with given id")?
            } else if let Some(branch) = branch {
                let branch = repo.find_branch(&branch, BranchType::Local)?;
                branch
                    .get()
            } else if let Some(branch) = &self.branch {
                let reference = repo.resolve_reference_from_short_name(branch)?;
                reference
                    .peel_to_tree()
                    .context("Couldn't find tree for branch")?
                    .context("Couldn't find tree for reference")?
            } else {
                let head = repo.head()?;
                head.peel_to_tree()
@@ -175,16 +179,14 @@ impl OpenRepository {
    }

    #[instrument(skip(self))]
    pub async fn tag_info(self: Arc<Self>, tag_name: &str) -> Result<DetailedTag> {
        let reference = format!("refs/tags/{tag_name}");
        let tag_name = tag_name.to_string();

    pub async fn tag_info(self: Arc<Self>) -> Result<DetailedTag> {
        tokio::task::spawn_blocking(move || {
            let tag_name = self.branch.clone().context("no tag given")?;
            let repo = self.repo.lock();

            let tag = repo
                .find_reference(&reference)
                .context("Given reference does not exist in repository")?
                .find_reference(&format!("refs/tags/{tag_name}"))
                .context("Given tag does not exist in repository")?
                .peel_to_tag()
                .context("Couldn't get to a tag from the given reference")?;
            let tag_target = tag.target().context("Couldn't find tagged object")?;
@@ -222,7 +224,12 @@ impl OpenRepository {
                tokio::task::spawn_blocking(move || {
                    let repo = self.repo.lock();

                    let head = repo.head().context("Couldn't find HEAD of repository")?;
                    let head = if let Some(reference) = &self.branch {
                        repo.resolve_reference_from_short_name(reference)?
                    } else {
                        repo.head().context("Couldn't find HEAD of repository")?
                    };

                    let commit = head.peel_to_commit().context(
                        "Couldn't find the commit that the HEAD of the repository refers to",
                    )?;
@@ -268,7 +275,12 @@ impl OpenRepository {
        tokio::task::spawn_blocking(move || {
            let repo = self.repo.lock();

            let head = repo.head().context("Couldn't find HEAD of repository")?;
            let head = if let Some(reference) = &self.branch {
                repo.resolve_reference_from_short_name(reference)?
            } else {
                repo.head().context("Couldn't find HEAD of repository")?
            };

            let commit = head
                .peel_to_commit()
                .context("Couldn't find commit HEAD of repository refers to")?;
@@ -381,7 +393,7 @@ pub enum TaggedObject {

#[derive(Debug)]
pub struct DetailedTag {
    pub name: String,
    pub name: Arc<str>,
    pub tagger: Option<CommitUser>,
    pub message: String,
    pub tagged_object: Option<TaggedObject>,
diff --git a/src/main.rs b/src/main.rs
index dc0ee65..75f0f66 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,8 +21,7 @@ use axum::{
use bat::assets::HighlightingAssets;
use clap::Parser;
use once_cell::sync::{Lazy, OnceCell};
use sha2::digest::FixedOutput;
use sha2::Digest;
use sha2::{digest::FixedOutput, Digest};
use sled::Db;
use syntect::html::ClassStyle;
use tokio::{
diff --git a/src/methods/repo/about.rs b/src/methods/repo/about.rs
index 7dda1e1..f8e86cf 100644
--- a/src/methods/repo/about.rs
+++ b/src/methods/repo/about.rs
@@ -1,7 +1,8 @@
use std::sync::Arc;

use askama::Template;
use axum::{response::Response, Extension};
use axum::{extract::Query, response::Response, Extension};
use serde::Deserialize;

use crate::{
    git::ReadmeFormat,
@@ -10,20 +11,35 @@ use crate::{
    Git,
};

#[derive(Deserialize)]
pub struct UriQuery {
    #[serde(rename = "h")]
    pub branch: Option<Arc<str>>,
}

#[derive(Template)]
#[template(path = "repo/about.html")]
pub struct View {
    repo: Repository,
    readme: Option<(ReadmeFormat, Arc<str>)>,
    branch: Option<Arc<str>>,
}

pub async fn handle(
    Extension(repo): Extension<Repository>,
    Extension(RepositoryPath(repository_path)): Extension<RepositoryPath>,
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.clone().repo(repository_path).await?;
    let open_repo = git
        .clone()
        .repo(repository_path, query.branch.clone())
        .await?;
    let readme = open_repo.readme().await?;

    Ok(into_response(&View { repo, readme }))
    Ok(into_response(&View {
        repo,
        readme,
        branch: query.branch,
    }))
}
diff --git a/src/methods/repo/commit.rs b/src/methods/repo/commit.rs
index e3fea6d..046a6c5 100644
--- a/src/methods/repo/commit.rs
+++ b/src/methods/repo/commit.rs
@@ -16,11 +16,14 @@ use crate::{
pub struct View {
    pub repo: Repository,
    pub commit: Arc<Commit>,
    pub branch: Option<Arc<str>>,
}

#[derive(Deserialize)]
pub struct UriQuery {
    pub id: Option<String>,
    #[serde(rename = "h")]
    pub branch: Option<Arc<str>>,
}

pub async fn handle(
@@ -29,12 +32,16 @@ pub async fn handle(
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.repo(repository_path).await?;
    let open_repo = git.repo(repository_path, query.branch.clone()).await?;
    let commit = if let Some(commit) = query.id {
        open_repo.commit(&commit).await?
    } else {
        Arc::new(open_repo.latest_commit().await?)
    };

    Ok(into_response(&View { repo, commit }))
    Ok(into_response(&View {
        repo,
        commit,
        branch: query.branch,
    }))
}
diff --git a/src/methods/repo/diff.rs b/src/methods/repo/diff.rs
index 942bc3d..ebc7e25 100644
--- a/src/methods/repo/diff.rs
+++ b/src/methods/repo/diff.rs
@@ -20,6 +20,7 @@ use crate::{
pub struct View {
    pub repo: Repository,
    pub commit: Arc<Commit>,
    pub branch: Option<Arc<str>>,
}

pub async fn handle(
@@ -28,14 +29,18 @@ pub async fn handle(
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.repo(repository_path).await?;
    let open_repo = git.repo(repository_path, query.branch.clone()).await?;
    let commit = if let Some(commit) = query.id {
        open_repo.commit(&commit).await?
    } else {
        Arc::new(open_repo.latest_commit().await?)
    };

    Ok(into_response(&View { repo, commit }))
    Ok(into_response(&View {
        repo,
        commit,
        branch: query.branch,
    }))
}

pub async fn handle_plain(
@@ -43,7 +48,7 @@ pub async fn handle_plain(
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.repo(repository_path).await?;
    let open_repo = git.repo(repository_path, query.branch).await?;
    let commit = if let Some(commit) = query.id {
        open_repo.commit(&commit).await?
    } else {
diff --git a/src/methods/repo/log.rs b/src/methods/repo/log.rs
index 5429120..6b07a4c 100644
--- a/src/methods/repo/log.rs
+++ b/src/methods/repo/log.rs
@@ -66,11 +66,22 @@ pub async fn get_branch_commits(
    amount: usize,
    offset: usize,
) -> Result<Vec<YokedCommit>> {
    let reference = branch.map(|branch| format!("refs/heads/{branch}"));
    if let Some(reference) = branch {
        let commit_tree = repository
            .get()
            .commit_tree(database, &format!("refs/heads/{reference}"))?;
        let commit_tree = commit_tree.fetch_latest(amount, offset).await;

    if let Some(reference) = reference {
        let commit_tree = repository.get().commit_tree(database, &reference)?;
        return Ok(commit_tree.fetch_latest(amount, offset).await);
        if !commit_tree.is_empty() {
            return Ok(commit_tree);
        }

        let tag_tree = repository
            .get()
            .commit_tree(database, &format!("refs/tags/{reference}"))?;
        let tag_tree = tag_tree.fetch_latest(amount, offset).await;

        return Ok(tag_tree);
    }

    for branch in DEFAULT_BRANCHES {
diff --git a/src/methods/repo/refs.rs b/src/methods/repo/refs.rs
index bf4f256..a085504 100644
--- a/src/methods/repo/refs.rs
+++ b/src/methods/repo/refs.rs
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::{collections::BTreeMap, sync::Arc};

use anyhow::Context;
use askama::Template;
@@ -17,6 +17,7 @@ use crate::{
pub struct View {
    repo: Repository,
    refs: Refs,
    branch: Option<Arc<str>>,
}

#[allow(clippy::unused_async)]
@@ -46,5 +47,6 @@ pub async fn handle(
    Ok(into_response(&View {
        repo,
        refs: Refs { heads, tags },
        branch: None,
    }))
}
diff --git a/src/methods/repo/smart_git.rs b/src/methods/repo/smart_git.rs
index 9791c5d..52d8fcb 100644
--- a/src/methods/repo/smart_git.rs
+++ b/src/methods/repo/smart_git.rs
@@ -14,8 +14,10 @@ use httparse::Status;
use tokio_util::io::StreamReader;
use tracing::warn;

use crate::methods::repo::{Repository, RepositoryPath, Result};
use crate::StatusCode;
use crate::{
    methods::repo::{Repository, RepositoryPath, Result},
    StatusCode,
};

#[allow(clippy::unused_async)]
pub async fn handle(
diff --git a/src/methods/repo/summary.rs b/src/methods/repo/summary.rs
index af88bce..4302c51 100644
--- a/src/methods/repo/summary.rs
+++ b/src/methods/repo/summary.rs
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::{collections::BTreeMap, sync::Arc};

use anyhow::Context;
use askama::Template;
@@ -20,6 +20,7 @@ pub struct View<'a> {
    repo: Repository,
    refs: Refs,
    commit_list: Vec<&'a crate::database::schema::commit::Commit<'a>>,
    branch: Option<Arc<str>>,
}

pub async fn handle(
@@ -51,6 +52,7 @@ pub async fn handle(
        repo,
        refs: Refs { heads, tags },
        commit_list,
        branch: None,
    }))
}

diff --git a/src/methods/repo/tag.rs b/src/methods/repo/tag.rs
index acdaf0c..e337d6b 100644
--- a/src/methods/repo/tag.rs
+++ b/src/methods/repo/tag.rs
@@ -14,7 +14,7 @@ use crate::{
#[derive(Deserialize)]
pub struct UriQuery {
    #[serde(rename = "h")]
    name: String,
    name: Arc<str>,
}

#[derive(Template)]
@@ -22,6 +22,7 @@ pub struct UriQuery {
pub struct View {
    repo: Repository,
    tag: DetailedTag,
    branch: Option<Arc<str>>,
}

pub async fn handle(
@@ -30,8 +31,12 @@ pub async fn handle(
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.repo(repository_path).await?;
    let tag = open_repo.tag_info(&query.name).await?;
    let open_repo = git.repo(repository_path, Some(query.name.clone())).await?;
    let tag = open_repo.tag_info().await?;

    Ok(into_response(&View { repo, tag }))
    Ok(into_response(&View {
        repo,
        tag,
        branch: Some(query.name),
    }))
}
diff --git a/src/methods/repo/tree.rs b/src/methods/repo/tree.rs
index 06d2e3c..0fc5f7d 100644
--- a/src/methods/repo/tree.rs
+++ b/src/methods/repo/tree.rs
@@ -4,7 +4,12 @@ use std::{
};

use askama::Template;
use axum::{extract::Query, http, response::IntoResponse, response::Response, Extension};
use axum::{
    extract::Query,
    http,
    response::{IntoResponse, Response},
    Extension,
};
use serde::Deserialize;

use crate::{
@@ -20,10 +25,10 @@ use crate::{
#[derive(Deserialize)]
pub struct UriQuery {
    id: Option<String>,
    #[serde(rename = "h")]
    branch: Option<String>,
    #[serde(default)]
    raw: bool,
    #[serde(rename = "h")]
    branch: Option<Arc<str>>,
}

impl Display for UriQuery {
@@ -50,6 +55,7 @@ pub struct TreeView {
    pub repo: Repository,
    pub items: Vec<TreeItem>,
    pub query: UriQuery,
    pub branch: Option<Arc<str>>,
}

#[derive(Template)]
@@ -57,6 +63,7 @@ pub struct TreeView {
pub struct FileView {
    pub repo: Repository,
    pub file: FileWithContent,
    pub branch: Option<Arc<str>>,
}

pub async fn handle(
@@ -66,19 +73,19 @@ pub async fn handle(
    Extension(git): Extension<Arc<Git>>,
    Query(query): Query<UriQuery>,
) -> Result<Response> {
    let open_repo = git.repo(repository_path).await?;
    let open_repo = git.repo(repository_path, query.branch.clone()).await?;

    Ok(
        match open_repo
            .path(
                child_path,
                query.id.as_deref(),
                query.branch.clone(),
                !query.raw,
            )
            .path(child_path, query.id.as_deref(), !query.raw)
            .await?
        {
            PathDestination::Tree(items) => into_response(&TreeView { repo, items, query }),
            PathDestination::Tree(items) => into_response(&TreeView {
                repo,
                items,
                branch: query.branch.clone(),
                query,
            }),
            PathDestination::File(file) if query.raw => {
                let headers = [(
                    http::header::CONTENT_TYPE,
@@ -87,7 +94,11 @@ pub async fn handle(

                (headers, file.content).into_response()
            }
            PathDestination::File(file) => into_response(&FileView { repo, file }),
            PathDestination::File(file) => into_response(&FileView {
                repo,
                file,
                branch: query.branch,
            }),
        },
    )
}
diff --git a/src/syntax_highlight.rs b/src/syntax_highlight.rs
index 45d9a10..f197351 100644
--- a/src/syntax_highlight.rs
+++ b/src/syntax_highlight.rs
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::io::Write;
use std::{collections::HashMap, io::Write};

use comrak::adapters::SyntaxHighlighterAdapter;
use syntect::{
diff --git a/templates/repo/base.html b/templates/repo/base.html
index 30a7456..057217f 100644
--- a/templates/repo/base.html
+++ b/templates/repo/base.html
@@ -1,3 +1,4 @@
{% import "macros/link.html" as link %}
{% extends "../base.html" %}

{% block title %}{{ repo.display() }}{% endblock %}
@@ -9,13 +10,13 @@
{% block nav %}
<nav>
    <div>
        <a href="/{{ repo.display() }}/about" class="{% block about_nav_class %}{% endblock %}">about</a>
        <a href="/{{ repo.display() }}/about{% call link::maybe_branch(branch) %}" class="{% block about_nav_class %}{% endblock %}">about</a>
        <a href="/{{ repo.display() }}" class="{% block summary_nav_class %}{% endblock %}">summary</a>
        <a href="/{{ repo.display() }}/refs" class="{% block refs_nav_class %}{% endblock %}">refs</a>
        <a href="/{{ repo.display() }}/log" class="{% block log_nav_class %}{% endblock %}">log</a>
        <a href="/{{ repo.display() }}/tree" class="{% block tree_nav_class %}{% endblock %}">tree</a>
        <a href="/{{ repo.display() }}/commit" class="{% block commit_nav_class %}{% endblock %}">commit</a>
        <a href="/{{ repo.display() }}/diff" class="{% block diff_nav_class %}{% endblock %}">diff</a>
        <a href="/{{ repo.display() }}/log{% call link::maybe_branch(branch) %}" class="{% block log_nav_class %}{% endblock %}">log</a>
        <a href="/{{ repo.display() }}/tree{% call link::maybe_branch(branch) %}" class="{% block tree_nav_class %}{% endblock %}">tree</a>
        <a href="/{{ repo.display() }}/commit{% call link::maybe_branch(branch) %}" class="{% block commit_nav_class %}{% endblock %}">commit</a>
        <a href="/{{ repo.display() }}/diff{% call link::maybe_branch(branch) %}" class="{% block diff_nav_class %}{% endblock %}">diff</a>
    </div>

    <div class="grow"></div>
diff --git a/templates/repo/commit.html b/templates/repo/commit.html
index caa78f0..39b0d6a 100644
--- a/templates/repo/commit.html
+++ b/templates/repo/commit.html
@@ -1,3 +1,4 @@
{% import "macros/link.html" as link %}
{% extends "repo/base.html" %}

{% block head %}
@@ -22,16 +23,16 @@
    </tr>
    <tr>
        <th>commit</th>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/commit?id={{ commit.oid() }}" class="no-style">{{ commit.oid() }}</a> <a href="/{{ repo.display() }}/patch?id={{ commit.oid() }}">[patch]</a></pre></td>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/commit?id={{ commit.oid() }}{% call link::maybe_branch_suffix(branch) %}" class="no-style">{{ commit.oid() }}</a> <a href="/{{ repo.display() }}/patch?id={{ commit.oid() }}">[patch]</a></pre></td>
    </tr>
    <tr>
        <th>tree</th>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/tree?id={{ commit.tree() }}" class="no-style">{{ commit.tree() }}</a></pre></td>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/tree?id={{ commit.tree() }}{% call link::maybe_branch_suffix(branch) %}" class="no-style">{{ commit.tree() }}</a></pre></td>
    </tr>
    {%- for parent in commit.parents() %}
    <tr>
        <th>parent</th>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/commit?id={{ parent }}" class="no-style">{{ parent }}</a></pre></td>
        <td colspan="2"><pre><a href="/{{ repo.display() }}/commit?id={{ parent }}{% call link::maybe_branch_suffix(branch) %}" class="no-style">{{ parent }}</a></pre></td>
    </tr>
    {%- endfor %}
    </tbody>
diff --git a/templates/repo/file.html b/templates/repo/file.html
index 9e09908..5e465de 100644
--- a/templates/repo/file.html
+++ b/templates/repo/file.html
@@ -1,3 +1,4 @@
{% import "macros/link.html" as link %}
{% extends "repo/base.html" %}

{% block head %}
@@ -8,7 +9,7 @@
{% block tree_nav_class %}active{% endblock %}

{% block extra_nav_links %}
    <a href="?raw=true">plain</a>
    <a href="?raw=true{% call link::maybe_branch_suffix(branch) %}">plain</a>
{% endblock %}

{% block content %}
diff --git a/templates/repo/log.html b/templates/repo/log.html
index af7bf23..354bc35 100644
--- a/templates/repo/log.html
+++ b/templates/repo/log.html
@@ -1,4 +1,5 @@
{% import "macros/refs.html" as refs %}
{% import "macros/link.html" as link %}
{% extends "repo/base.html" %}

{% block log_nav_class %}active{% endblock %}
@@ -10,11 +11,7 @@

{% if let Some(next_offset) = next_offset %}
<div class="mt-2 text-center">
    {% if let Some(branch) = branch %}
        <a href="?h={{ branch }}&ofs={{ next_offset }}">[next]</a>
    {% else %}
        <a href="?ofs={{ next_offset }}">[next]</a>
    {% endif %}
    <a href="?ofs={{ next_offset }}{% call link::maybe_branch_suffix(branch) %}">[next]</a>
</div>
{% endif %}
{% endblock %}
diff --git a/templates/repo/macros/link.html b/templates/repo/macros/link.html
new file mode 100644
index 0000000..b311f7e
--- /dev/null
+++ b/templates/repo/macros/link.html
@@ -0,0 +1,3 @@
{%- macro maybe_branch(branch) -%}{% if let Some(branch) = branch %}?h={{ branch }}{% endif %}{%- endmacro -%}

{%- macro maybe_branch_suffix(branch) -%}{% if let Some(branch) = branch %}&h={{ branch }}{% endif %}{%- endmacro -%}
diff --git a/templates/repo/tag.html b/templates/repo/tag.html
index e00abfa..6c78733 100644
--- a/templates/repo/tag.html
+++ b/templates/repo/tag.html
@@ -1,3 +1,4 @@
{% import "macros/link.html" as link %}
{% extends "repo/base.html" %}

{% block content %}
@@ -23,7 +24,7 @@
            <td>
                {% match tagged_object %}
                    {% when crate::git::TaggedObject::Commit with (commit) %}
                        <a href="/{{ repo.display() }}/commit?id={{ commit }}">commit {{ commit|truncate(10) }}...</a>
                        <a href="/{{ repo.display() }}/commit?id={{ commit }}{% call link::maybe_branch_suffix(branch) %}">commit {{ commit|truncate(10) }}...</a>
                    {% when crate::git::TaggedObject::Tree with (tree) %}
                        tree {{ tree }}
                {% endmatch %}