🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Creating a repository, and the page one is shown on.
//!
//! Creating is a two-step thing that has to stay in step: a bare repository on
//! disk, under the owner's directory, and a row naming it. The disk comes
//! first - a row without a directory behind it is a repository nothing can
//! reach - and a failure to write the row removes the directory again rather
//! than leaving the two disagreeing.

pub mod patches;
pub mod settings;
pub mod tickets;

use std::path::{Path, PathBuf};
use std::sync::Arc;

use actix_web::http::header;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;

use crate::auth::{CurrentUser, viewer_name};
use crate::entities::repositories::Model as Repository;
use crate::entities::users;
use crate::repositories::{self, RepositoryPath, store};
use crate::views::{self, RepositoryForm, RepositorySummary};
use crate::{AppState, highlight, markdown, state_read};

/// What the new repository form posts.
#[derive(Debug, Deserialize)]
pub struct NewRepository {
    slug: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    visibility: String,
}

/// The user `name`, when `current` is allowed to act as them.
///
/// Returns the response to send instead when the user is missing or the
/// request belongs to somebody else. An anonymous request never gets here:
/// [`CurrentUser`] turns it into a `401` first.
///
/// The response is boxed because it is only ever built on the way out: an
/// `HttpResponse` is large, and a `Result` that carries one unboxed makes every
/// successful call move it around.
pub(crate) async fn acting_user(
    data: &web::Data<Arc<RwLock<AppState>>>,
    current: &CurrentUser,
    name: &str,
) -> Result<users::Model, Box<HttpResponse>> {
    let user = {
        let state = state_read(data).await;
        match store::find_user(&state.db, name).await {
            Ok(user) => user,
            Err(error) => {
                error!(%error, user = %name, "looking up a user failed");
                return Err(Box::new(HttpResponse::InternalServerError().finish()));
            }
        }
    };

    let Some(user) = user else {
        return Err(Box::new(
            HttpResponse::NotFound().body(format!("User `{name}` could not be found")),
        ));
    };

    if user.id != current.id {
        return Err(Box::new(HttpResponse::Forbidden().finish()));
    }

    Ok(user)
}

/// The repository at `owner_name/slug` as `viewer` may read it, with the
/// directory its bare repositories live under.
///
/// A private repository is answered exactly as one that does not exist: the
/// two are the same word, because someone who may not read it is never told
/// that it is there.
///
/// The error case carries the response to send - boxed for the reason
/// [`acting_user`] gives.
pub(crate) async fn readable_repository(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<&CurrentUser>,
    owner_name: &str,
    slug: &str,
) -> Result<(users::Model, Repository, PathBuf), Box<HttpResponse>> {
    let (found, repo_root) = {
        let state = state_read(data).await;
        let found = match store::find_repository_by_path(&state.db, owner_name, slug).await {
            Ok(found) => found,
            Err(error) => {
                error!(%error, owner = %owner_name, slug = %slug, "looking up a repository failed");
                return Err(Box::new(HttpResponse::InternalServerError().finish()));
            }
        };
        (found, state.repo_root.clone())
    };

    let missing = || {
        Box::new(HttpResponse::NotFound().body(format!(
            "Repository `{owner_name}/{slug}` could not be found"
        ))) as Box<HttpResponse>
    };

    let Some((owner, repository)) = found else {
        return Err(missing());
    };

    if !repositories::may_read(
        repository.visibility,
        repository.owner_id,
        viewer.map(|viewer| viewer.id),
    ) {
        return Err(missing());
    }

    Ok((owner, repository, repo_root))
}

#[get("/~{user_or_team}/repositories/new")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_new_repository(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    views::new_repository(Some(&current.name), &user.name, &RepositoryForm::default())
}

#[post("/~{user_or_team}/repositories")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_new_repository(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
    form: web::Form<NewRepository>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let form = form.into_inner();
    let slug = form.slug.trim();
    let visibility = repositories::visibility_from_form(&form.visibility);

    // Render the form back, with the values the visitor typed, rather than a
    // bare error: retyping a rejected name is the least useful thing to ask.
    // The label is kept rather than the value itself so the closure borrows
    // nothing that goes on to be stored.
    let visibility_label = visibility.label();
    let refuse = |error: String| {
        views::new_repository(
            Some(&current.name),
            &user.name,
            &RepositoryForm {
                slug: Some(form.slug.clone()),
                description: Some(form.description.clone()),
                visibility: visibility_label,
                error: Some(error),
            },
        )
    };

    if !repositories::is_slug(slug) {
        return refuse(format!(
            "`{slug}` cannot name a repository: use letters, digits, `-`, `_` and `.`, \
             starting and ending with a letter or digit, up to {} characters.",
            repositories::MAX_SLUG_LEN
        ));
    }

    // A slug that is otherwise valid may still be a page of the webui's own.
    if repositories::is_reserved(slug) {
        return refuse(format!(
            "`{slug}` is reserved by the webui. Pick another name."
        ));
    }

    let slug = slug.to_string();
    let (db, repo_root) = {
        let state = state_read(&data).await;
        (state.db.clone(), state.repo_root.clone())
    };

    // Checked with the deleted rows included, because the uniqueness the
    // database enforces does not care about `deleted_at`: a repository that
    // was removed still holds its name, and saying so here beats letting the
    // insert fail on the constraint.
    match store::find_any_repository(&db, user.id, &slug).await {
        Ok(None) => {}
        Ok(Some(_)) => return refuse(format!("You already have a repository called `{slug}`.")),
        Err(error) => {
            error!(%error, user = %user.name, slug = %slug, "checking for a repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    }

    let path = RepositoryPath {
        owner: user.name.clone(),
        slug: slug.clone(),
    };
    let bare = path.bare_path(&repo_root);

    // Disk first: a row that no directory backs is a repository that cannot be
    // reached, and cleaning that up afterwards is the harder direction.
    if let Err(error) = repositories::init_bare(&bare).await {
        error!(%error, path = %bare.display(), "creating the bare repository failed");
        return HttpResponse::InternalServerError().finish();
    }

    let description = form.description.trim();
    let description = (!description.is_empty()).then_some(description);

    match store::create_repository(&db, user.id, &slug, description, visibility).await {
        Ok(repository) => {
            // Seed the default labels for the new repository
            if let Err(error) =
                crate::tickets::store::ensure_default_labels(&db, repository.id).await
            {
                error!(%error, user = %user.name, slug = %slug, "seeding default labels failed");
                // Clean up the repository if seeding fails
                if let Err(error) = std::fs::remove_dir_all(&bare) {
                    error!(%error, path = %bare.display(), "cleaning up the bare repository failed");
                }
                return HttpResponse::InternalServerError().finish();
            }
            HttpResponse::SeeOther()
                .append_header(("Location", format!("/~{}/{slug}", user.name)))
                .finish()
        }
        Err(error) => {
            error!(%error, user = %user.name, slug = %slug, "recording the repository failed");
            // Undo the half that did land, so the next attempt is not refused
            // for a directory nobody can see.
            if let Err(error) = std::fs::remove_dir_all(&bare) {
                error!(%error, path = %bare.display(), "cleaning up the bare repository failed");
            }
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[tracing::instrument(
    skip_all,
    fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
pub async fn get_repository(
    data: web::Data<Arc<RwLock<AppState>>>,
    req: HttpRequest,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    // The path arrives as a pair, so the span's fields are recorded by hand
    // rather than named in the attribute.
    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());

    // What the visitor's browser would prefer to read, most wanted first. Used
    // to pick a localised readme.
    let languages = accept_languages(
        req.headers()
            .get(header::ACCEPT_LANGUAGE)
            .and_then(|value| value.to_str().ok()),
    );

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    // The SSH settings have their own read of the state, after the lookup
    // above released it.
    let ssh = { state_read(&data).await.ssh.clone() };

    // Read outside the lock: spawning `git` is slow enough that no other
    // request should wait behind it.
    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    let branches =
        match repositories::git::branches(&bare, Some(repositories::git::REF_LIMIT)).await {
            Ok(branches) => branches,
            Err(error) => {
                error!(%error, path = %bare.display(), "listing a repository's branches failed");
                Vec::new()
            }
        };
    let tags = match repositories::git::tags(&bare, Some(repositories::git::REF_LIMIT)).await {
        Ok(tags) => tags,
        Err(error) => {
            error!(%error, path = %bare.display(), "listing a repository's tags failed");
            Vec::new()
        }
    };
    let readme =
        match repositories::git::readme(&bare, &repository.default_branch, &languages).await {
            Ok(readme) => readme,
            Err(error) => {
                error!(%error, path = %bare.display(), "reading a repository's readme failed");
                None
            }
        };

    // A signed-in visitor is offered the way they can write; anyone else only
    // the way they can read.
    let clone_url = if viewer.is_some() {
        ssh.clone_url(&owner.name, &repository.slug)
    } else {
        http_clone_url(&req, &owner.name, &repository.slug)
    };

    let detail = views::RepositoryDetail {
        slug: repository.slug.clone(),
        description: repository.description.clone(),
        visibility: repository.visibility.label(),
        default_branch: repository.default_branch.clone(),
        clone_url,
        readme: readme.map(|readme| {
            let html = markdown::render_with_repo_context(
                &readme.content,
                &owner.name,
                &repository.slug,
                &repository.default_branch,
                &readme.path,
            );
            views::ReadmeView {
                path: readme.path,
                html,
            }
        }),
        branches: branches.into_iter().map(ref_summary).collect(),
        tags: tags.into_iter().map(ref_summary).collect(),
    };

    views::repository(viewer_name(viewer.as_ref()), &owner.name, &detail)
}

/// The repository's refs: every branch and tag it has, newest first.
///
/// Same address shape as [`get_repository`] with one more segment, so it
/// needs no reserved slug - `/~{user}/refs` is still a repository - and the
/// two patterns cannot reach each other's pages.
#[get("/~{user_or_team}/{repository_slug}/refs")]
#[tracing::instrument(
    skip_all,
    fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
async fn get_refs(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    // Read outside the lock: spawning `git` is slow enough that no other
    // request should wait behind it.
    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // No limit - this page is the complete listing whose first three of each
    // the landing page previews. A failure empties the section rather than
    // failing the page, the same way the landing page treats it.
    let branches = match repositories::git::branches(&bare, None).await {
        Ok(branches) => branches,
        Err(error) => {
            error!(%error, path = %bare.display(), "listing a repository's branches failed");
            Vec::new()
        }
    };
    let tags = match repositories::git::tags(&bare, None).await {
        Ok(tags) => tags,
        Err(error) => {
            error!(%error, path = %bare.display(), "listing a repository's tags failed");
            Vec::new()
        }
    };

    let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
    let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();

    views::refs(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &repository.slug,
        repository.visibility.label(),
        &repository.default_branch,
        &branches,
        &tags,
    )
}

/// The diff of one commit.
///
/// `/~{owner}/{repository_slug}/commit/{rev}` - the revision is a single
/// commit whose changes to show. Unlike tree or raw addresses, there is no
/// path within the revision - just the revision itself.
#[get("/~{user_or_team}/{repository_slug}/commit/{rev:.*}")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        rev = tracing::field::Empty,
        reference = tracing::field::Empty,
    )
)]
async fn get_commit(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, rev) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("rev", rev.as_str());

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    commit_address(&data, viewer, owner, repository, bare, rev).await
}

/// A single commit's diff, read and rendered.
///
/// The `rev` must resolve to a commit via
/// [`repositories::git::resolves_to_commit`]; otherwise the page is `404`. A
/// `rev` starting with `-` is refused to avoid argument injection, the same
/// guard [`commits_address`] uses.
async fn commit_address(
    _data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner: users::Model,
    repository: Repository,
    bare: std::path::PathBuf,
    rev: String,
) -> HttpResponse {
    let span = tracing::Span::current();

    // Refuse revisions starting with `-` to avoid argument injection.
    if rev.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{rev}` names no revision of `{}/{}`",
            owner.name, repository.slug
        ));
    }

    // Check whether the revision resolves to a commit.
    let reaches = match repositories::git::resolves_to_commit(&bare, &rev).await {
        Ok(reaches) => reaches,
        Err(error) => {
            error!(%error, path = %bare.display(), "resolving a revision failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !reaches {
        return HttpResponse::NotFound().body(format!(
            "`{rev}` names no revision of `{}/{}`",
            owner.name, repository.slug
        ));
    }

    span.record("reference", rev.as_str());

    // Fetch the commit metadata.
    let commit = match repositories::git::commit(&bare, &rev).await {
        Ok(Some(commit)) => commit,
        Ok(None) => {
            return HttpResponse::NotFound().body(format!(
                "`{rev}` names no revision of `{}/{}`",
                owner.name, repository.slug
            ));
        }
        Err(error) => {
            error!(%error, path = %bare.display(), "reading commit metadata failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the diff.
    let files = match repositories::git::commit_diff(&bare, &rev).await {
        Ok(Some(files)) => files,
        Ok(None) => {
            // Should not happen if resolves_to_commit returned true, but handle
            // it.
            return HttpResponse::NotFound().body(format!(
                "`{rev}` names no revision of `{}/{}`",
                owner.name, repository.slug
            ));
        }
        Err(error) => {
            error!(%error, path = %bare.display(), "reading commit diff failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    views::commit(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &repository.slug,
        repository.visibility.label(),
        &repository.default_branch,
        &commit,
        &files,
    )
}

/// The commit history of one revision: every commit reachable from it, newest
/// first.
///
/// `/~{owner}/{repository_slug}/commits/{tail}` - the tail is the revision
/// whose history to show. Unlike tree or raw addresses, there is no path
/// within the revision - just the revision itself.
#[get("/~{user_or_team}/{repository_slug}/commits/{tail:.*}")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        tail = tracing::field::Empty,
        reference = tracing::field::Empty,
    )
)]
async fn get_commits(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, tail) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("tail", tail.as_str());

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    commits_address(&data, viewer, owner, repository, bare, tail).await
}

/// Query parameters for the compare landing form.
#[derive(Debug, Deserialize)]
struct CompareQuery {
    from: Option<String>,
    to: Option<String>,
}

/// The compare landing page with a form to pick two revisions.
#[get("/~{user_or_team}/{repository_slug}/compare")]
async fn get_compare_root(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<CompareQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    compare_landing_address(&data, viewer, owner, repository, bare, query.into_inner()).await
}

/// The same compare landing page with a trailing slash.
#[get("/~{user_or_team}/{repository_slug}/compare/")]
async fn get_compare_root_slash(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<CompareQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    compare_landing_address(&data, viewer, owner, repository, bare, query.into_inner()).await
}

/// The compare page showing the diff between two revisions.
#[get("/~{user_or_team}/{repository_slug}/compare/{tail:.*}")]
async fn get_compare(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, tail) = path.into_inner();

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // If tail is empty, show the landing page
    if tail.is_empty() {
        return compare_landing_address(
            &data,
            viewer,
            owner,
            repository,
            bare,
            CompareQuery {
                from: None,
                to: None,
            },
        )
        .await;
    }

    compare_address(&data, viewer, owner, repository, bare, tail).await
}

/// Handles the compare landing page logic: either redirects to the canonical
/// compare URL if both ends are valid, or shows the picker form.
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
    )
)]
async fn compare_landing_address(
    _data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner: users::Model,
    repository: Repository,
    bare: std::path::PathBuf,
    query: CompareQuery,
) -> HttpResponse {
    let span = tracing::Span::current();
    span.record("owner", owner.name.as_str());
    span.record("repository_slug", repository.slug.as_str());

    let from = query.from.as_deref();
    let to = query.to.as_deref();

    // If we have any query parameters, validate them
    if from.is_some() || to.is_some() {
        if let (Some(from), Some(to)) = (from, to) {
            if usable_compare_end(from) && usable_compare_end(to) {
                // Both present and valid - redirect
                return HttpResponse::SeeOther()
                    .append_header((
                        header::LOCATION,
                        format!(
                            "/~{owner}/{slug}/compare/{from}...{to}",
                            owner = owner.name,
                            slug = repository.slug,
                            from = from,
                            to = to,
                        ),
                    ))
                    .finish();
            } else {
                // Both present but invalid
                return compare_landing(
                    _data,
                    &repository,
                    &bare,
                    viewer_name(viewer.as_ref()),
                    &owner.name,
                    &repository.slug,
                    repository.visibility.label(),
                    &repository.default_branch,
                    Some("Invalid comparison endpoints."),
                )
                .await;
            }
        } else {
            // One present, one missing
            return compare_landing(
                _data,
                &repository,
                &bare,
                viewer_name(viewer.as_ref()),
                &owner.name,
                &repository.slug,
                repository.visibility.label(),
                &repository.default_branch,
                Some("A comparison needs both endpoints."),
            )
            .await;
        }
    }

    // No query parameters - show the picker
    compare_landing(
        _data,
        &repository,
        &bare,
        viewer_name(viewer.as_ref()),
        &owner.name,
        &repository.slug,
        repository.visibility.label(),
        &repository.default_branch,
        None,
    )
    .await
}

/// Renders the compare landing page with branches and tags for selection.
async fn compare_landing(
    data: &web::Data<Arc<RwLock<AppState>>>,
    repository: &Repository,
    bare: &Path,
    viewer: Option<&str>,
    owner: &str,
    slug: &str,
    visibility: &'static str,
    default_branch: &str,
    error: Option<&str>,
) -> HttpResponse {
    // Fetch branches and tags
    let branches = match repositories::git::branches(bare, None).await {
        Ok(branches) => branches,
        Err(e) => {
            tracing::error!(%e, "failed to fetch branches");
            Vec::new()
        }
    };
    let tags = match repositories::git::tags(bare, None).await {
        Ok(tags) => tags,
        Err(e) => {
            tracing::error!(%e, "failed to fetch tags");
            Vec::new()
        }
    };

    let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
    let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();

    // Fetch open patches from the database
    let db = {
        let state = state_read(data).await;
        state.db.clone()
    };
    let repository_id = repository.id;
    let patches = match crate::patches::store::list(
        &db,
        repository_id,
        Some(crate::entities::patch_proposals::Status::Open),
    )
    .await
    {
        Ok(patches) => patches
            .into_iter()
            .map(|p| views::PatchSummary {
                number: p.number,
                summary: p.summary,
            })
            .collect(),
        Err(e) => {
            tracing::error!(%e, "failed to fetch patches");
            Vec::new()
        }
    };

    views::compare_form(
        viewer,
        owner,
        slug,
        visibility,
        default_branch,
        &branches,
        &tags,
        &patches,
        error,
    )
}

/// Handles the actual compare page logic: validates the range, fetches commits
/// and diff, and renders the compare view.
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        tail = tracing::field::Empty,
        base = tracing::field::Empty,
        head = tracing::field::Empty,
    )
)]
async fn compare_address(
    _data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner: users::Model,
    repository: Repository,
    bare: std::path::PathBuf,
    tail: String,
) -> HttpResponse {
    let span = tracing::Span::current();
    span.record("owner", owner.name.as_str());
    span.record("repository_slug", repository.slug.as_str());
    span.record("tail", tail.as_str());

    // Split the range
    let (base, head) = match split_compare_range(&tail) {
        Some((base, head)) => (base, head),
        None => {
            return HttpResponse::NotFound().body(format!(
                "`{tail}` is not a comparison of `{}/{}`: the address takes the shape `{{base}}...{{head}}`",
                owner.name, repository.slug
            ));
        }
    };

    span.record("base", base);
    span.record("head", head);

    // Validate both ends
    if base.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{}` names no revision of `{}/{}`",
            base, owner.name, repository.slug
        ));
    }
    if head.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{}` names no revision of `{}/{}`",
            head, owner.name, repository.slug
        ));
    }

    // Check both ends resolve
    let base_resolves = match repositories::git::resolves_to_commit(&bare, base).await {
        Ok(resolves) => resolves,
        Err(e) => {
            tracing::error!(%e, "failed to resolve base");
            return HttpResponse::InternalServerError().finish();
        }
    };
    if !base_resolves {
        return HttpResponse::NotFound().body(format!(
            "`{}` names no revision of `{}/{}`",
            base, owner.name, repository.slug
        ));
    }

    let head_resolves = match repositories::git::resolves_to_commit(&bare, head).await {
        Ok(resolves) => resolves,
        Err(e) => {
            tracing::error!(%e, "failed to resolve head");
            return HttpResponse::InternalServerError().finish();
        }
    };
    if !head_resolves {
        return HttpResponse::NotFound().body(format!(
            "`{}` names no revision of `{}/{}`",
            head, owner.name, repository.slug
        ));
    }

    // Fetch commits in range
    let commits = match repositories::git::history(&bare, &format!("{base}..{head}")).await {
        Ok(Some(commits)) => commits,
        Ok(None) => Vec::new(),
        Err(e) => {
            tracing::error!(%e, "failed to fetch commits");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch diff
    let files = match repositories::git::compare_diff(&bare, &format!("{base}...{head}")).await {
        Ok(Some(files)) => files,
        Ok(None) => {
            return HttpResponse::NotFound().body(format!(
                "`{}` and `{}` have no common ancestor in `{}/{}`",
                base, head, owner.name, repository.slug
            ));
        }
        Err(e) => {
            tracing::error!(%e, "failed to fetch diff");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Convert commits to summaries
    let summaries: Vec<views::CommitSummary> = commits.into_iter().map(commit_summary).collect();

    views::compare(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &repository.slug,
        repository.visibility.label(),
        &repository.default_branch,
        base,
        head,
        &summaries,
        &files,
    )
}

/// Converts a git::Commit to a views::CommitSummary.
fn commit_summary(commit: repositories::git::Commit) -> views::CommitSummary {
    views::CommitSummary {
        hash: commit.hash,
        full_hash: commit.full_hash,
        author: commit.author,
        date: commit.date,
        subject: commit.subject,
        files_changed: commit.files_changed,
        insertions: commit.insertions,
        deletions: commit.deletions,
    }
}

/// Query parameters for the commits root route.
#[derive(Debug, Deserialize)]
struct CommitsQuery {
    #[serde(rename = "ref")]
    ref_name: Option<String>,
}

/// The commits root: `/commits` redirects to the default branch's history.
#[get("/~{user_or_team}/{repository_slug}/commits")]
async fn get_commits_root(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<CommitsQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let (_, repository, _) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let reference = query
        .ref_name
        .as_deref()
        .unwrap_or(&repository.default_branch);

    // Validate the reference before redirecting
    if reference.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{reference}` names no revision of `{}/{}`",
            owner_name, repository_slug
        ));
    }

    HttpResponse::SeeOther()
        .append_header((
            header::LOCATION,
            format!(
                "/~{owner}/{slug}/commits/{reference}",
                owner = owner_name,
                slug = repository_slug,
                reference = reference
            ),
        ))
        .finish()
}

/// The same bare address with its trailing slash.
#[get("/~{user_or_team}/{repository_slug}/commits/")]
async fn get_commits_root_slash(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<CommitsQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let (_, repository, _) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let reference = query
        .ref_name
        .as_deref()
        .unwrap_or(&repository.default_branch);

    // Validate the reference before redirecting
    if reference.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{reference}` names no revision of `{}/{}`",
            owner_name, repository_slug
        ));
    }

    HttpResponse::SeeOther()
        .append_header((
            header::LOCATION,
            format!(
                "/~{owner}/{slug}/commits/{reference}",
                owner = owner_name,
                slug = repository_slug,
                reference = reference
            ),
        ))
        .finish()
}

/// The commit history of one revision, read and rendered.
///
/// The tail must resolve to a commit via [`resolves_to_commit`]; otherwise
/// the page is `404`. A tail starting with `-` is refused to avoid argument
/// injection, the same guard [`resolve_address`] uses for tree addresses.
/// When the tail equals the repository's default branch but the branch is
/// unborn (no commits yet), the page renders empty rather than `404` - an
/// empty repository has no history to show, not a missing one.
async fn commits_address(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner: users::Model,
    repository: Repository,
    bare: std::path::PathBuf,
    tail: String,
) -> HttpResponse {
    let span = tracing::Span::current();

    // Refuse tails starting with `-` to avoid argument injection.
    if tail.starts_with('-') {
        return HttpResponse::NotFound().body(format!(
            "`{tail}` names no revision of `{}/{}`",
            owner.name, repository.slug
        ));
    }

    // Check whether the tail resolves to a commit.
    let reaches = match repositories::git::resolves_to_commit(&bare, &tail).await {
        Ok(reaches) => reaches,
        Err(error) => {
            error!(%error, path = %bare.display(), "resolving a revision failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !reaches {
        // If the tail equals the default branch, check whether it's unborn.
        if tail == repository.default_branch {
            // Unborn default branch: render an empty page.
            return views::commits(
                viewer_name(viewer.as_ref()),
                &owner.name,
                &repository.slug,
                repository.visibility.label(),
                &repository.default_branch,
                &tail,
                &[],
                &[],
                &[],
                &[],
            );
        } else {
            return HttpResponse::NotFound().body(format!(
                "`{tail}` names no revision of `{}/{}`",
                owner.name, repository.slug
            ));
        }
    }

    span.record("reference", tail.as_str());

    // Fetch the commit history.
    let commits = match repositories::git::history(&bare, &tail).await {
        Ok(Some(commits)) => commits,
        Ok(None) => {
            // Should not happen if resolves_to_commit returned true, but handle
            // it.
            return HttpResponse::NotFound().body(format!(
                "`{tail}` names no revision of `{}/{}`",
                owner.name, repository.slug
            ));
        }
        Err(error) => {
            error!(%error, path = %bare.display(), "reading commit history failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let commit_summaries: Vec<views::CommitSummary> =
        commits.into_iter().map(commit_summary).collect();

    // Fetch branches and tags for the ref selector
    let branches = match repositories::git::branches(&bare, None).await {
        Ok(branches) => branches,
        Err(e) => {
            error!(%e, "failed to fetch branches");
            Vec::new()
        }
    };
    let tags = match repositories::git::tags(&bare, None).await {
        Ok(tags) => tags,
        Err(e) => {
            error!(%e, "failed to fetch tags");
            Vec::new()
        }
    };

    let branches: Vec<views::RefSummary> = branches.into_iter().map(ref_summary).collect();
    let tags: Vec<views::RefSummary> = tags.into_iter().map(ref_summary).collect();

    // Fetch open patches from the database
    let db = {
        let state = state_read(data).await;
        state.db.clone()
    };
    let repository_id = repository.id;
    let patches = match crate::patches::store::list(
        &db,
        repository_id,
        Some(crate::entities::patch_proposals::Status::Open),
    )
    .await
    {
        Ok(patches) => patches
            .into_iter()
            .map(|p| views::PatchSummary {
                number: p.number,
                summary: p.summary,
            })
            .collect(),
        Err(e) => {
            error!(%e, "failed to fetch patches");
            Vec::new()
        }
    };

    views::commits(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &repository.slug,
        repository.visibility.label(),
        &repository.default_branch,
        &tail,
        &commit_summaries,
        &branches,
        &tags,
        &patches,
    )
}

/// One address of one ref: a directory to walk into, or a file to read.
///
/// `/~{owner}/{repository_slug}/tree/{ref_or_commit}/{file_path}` - a
/// revision, then the path within it. The two pieces are read as one
/// address again before anything resolves them ([`tree_address`]), because
/// a ref may hold a slash of its own.
#[get("/~{user_or_team}/{repository_slug}/tree/{ref_or_commit}/{file_path:.*}")]
async fn get_tree(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, ref_or_commit, file_path) = path.into_inner();
    tree_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        ref_or_commit,
        file_path,
    )
    .await
}

/// The same address stopping at the revision: `/tree/{ref_or_commit}` is
/// the root of a ref - a tree, or a file at the top of it - rather than
/// anything beneath it.
#[get("/~{user_or_team}/{repository_slug}/tree/{ref_or_commit}")]
async fn get_tree_revision(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, ref_or_commit) = path.into_inner();
    tree_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        ref_or_commit,
        String::new(),
    )
    .await
}

/// What a tree address names, read the way this forge reads every address
/// of a ref.
///
/// The route splits the address after its first slash - a revision segment
/// and everything under it - but a ref may hold slashes of its own, so the
/// pieces are put back together ([`joined_address`]) and the whole address
/// is read longest-ref-first ([`resolve_tail`]): `feature/x/pkg` is `pkg`
/// on the branch `feature/x`, not a path under a branch called `feature`;
/// failing that, git may still know the whole address as a revision, which
/// is how a bare commit hash is browsed. What the path turns out to be
/// decides the page: a directory lists itself, a file shows itself.
///
/// An address no ref or revision answers for is a `404` - except the branch
/// the repository opens on, which the browse tab of every page leads to and
/// which does not resolve before anything has been pushed: that is an empty
/// repository, and an empty tree to show for it, not a missing page. The
/// address with no revision in it at all settles on that same branch with a
/// redirect instead, and a private repository is not redirected either: the
/// lookup runs first, so the answer is the one everything else gives.
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        ref_or_commit = tracing::field::Empty,
        file_path = tracing::field::Empty,
        reference = tracing::field::Empty,
        within = tracing::field::Empty,
    )
)]
async fn tree_address(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner_name: String,
    repository_slug: String,
    ref_or_commit: String,
    file_path: String,
) -> HttpResponse {
    // The address arrives as pieces, so the span's fields are recorded by
    // hand rather than named in the attribute.
    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    // What the address asked for, exactly as it was asked: the resolution
    // recorded beside it is what the forge understood by it.
    span.record("ref_or_commit", ref_or_commit.as_str());
    span.record("file_path", file_path.as_str());

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    // Read outside the lock: spawning `git` is slow enough that no other
    // request should wait behind it.
    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // The two pieces the route split at are one address again here: where a
    // revision ends and its path begins is for the repository's refs to say,
    // not for the first slash in the URL.
    let tail = joined_address(&ref_or_commit, &file_path);

    // The address with no revision asks for the one the repository opens
    // on - the branch the browse tab itself leads to.
    if tail.is_empty() {
        return HttpResponse::SeeOther()
            .append_header((
                header::LOCATION,
                format!(
                    "/~{}/{}/tree/{}",
                    owner.name, repository.slug, repository.default_branch
                ),
            ))
            .finish();
    }

    let (reference, within) = match resolve_address(&bare, &tail, &repository.default_branch).await
    {
        Address::Found { reference, within } => (reference, within),
        Address::Unborn => {
            return views::tree(
                viewer_name(viewer.as_ref()),
                &owner.name,
                &views::TreeDetail {
                    slug: repository.slug.clone(),
                    visibility: repository.visibility.label(),
                    default_branch: repository.default_branch.clone(),
                    reference: tail,
                    path: String::new(),
                    entries: Vec::new(),
                },
            );
        }
        Address::Missing => {
            return HttpResponse::NotFound().body(format!(
                "`{tail}` names no ref or revision of `{owner_name}/{repository_slug}`"
            ));
        }
    };
    span.record("reference", reference.as_str());
    span.record("within", within.as_str());

    // The root of a revision is always somewhere to walk into; below it,
    // what the path names decides the page.
    let kind = if within.is_empty() {
        Some(repositories::git::ObjectKind::Tree)
    } else {
        match repositories::git::object_kind(&bare, &format!("{reference}:{within}")).await {
            Ok(kind) => kind,
            Err(error) => {
                // A fault reading the repository empties the page rather
                // than failing it, the way the landing page treats its
                // listings: fall through to the listing, which answers it
                // that way.
                error!(%error, path = %bare.display(), "asking git what a path is failed");
                Some(repositories::git::ObjectKind::Tree)
            }
        }
    };

    match kind {
        Some(repositories::git::ObjectKind::Blob) => {
            file_page(
                &bare,
                viewer.as_ref(),
                &owner,
                &repository,
                &reference,
                &within,
            )
            .await
        }
        Some(repositories::git::ObjectKind::Tree) => {
            let entries = match repositories::git::listing(&bare, &reference, &within).await {
                Ok(None) => {
                    // Git said so plainly: there is no such directory, or
                    // the ref does not reach a tree at all - a tag on a
                    // blob, say, whose root has no listing.
                    let described = if within.is_empty() {
                        format!("`{reference}`")
                    } else {
                        format!("`{reference}:{within}`")
                    };
                    return HttpResponse::NotFound().body(format!(
                        "{described} is not a tree of `{owner_name}/{repository_slug}`"
                    ));
                }
                Ok(Some(entries)) => entries,
                Err(error) => {
                    // A fault reading the repository empties the page rather
                    // than failing it, the way the landing page treats its
                    // listings.
                    error!(%error, path = %bare.display(), "listing a directory failed");
                    Vec::new()
                }
            };

            let mut entries: Vec<views::TreeEntry> = entries
                .into_iter()
                .map(|entry| views::TreeEntry {
                    name: entry.name,
                    is_directory: entry.kind == repositories::git::EntryKind::Directory,
                })
                .collect();

            // Directories first, the shape of a file tree everywhere, and then
            // by name: what a reader scanning a page wants, rather
            // than git's own byte order.
            entries.sort_by(|left, right| {
                right
                    .is_directory
                    .cmp(&left.is_directory)
                    .then_with(|| left.name.cmp(&right.name))
            });

            views::tree(
                viewer_name(viewer.as_ref()),
                &owner.name,
                &views::TreeDetail {
                    slug: repository.slug.clone(),
                    visibility: repository.visibility.label(),
                    default_branch: repository.default_branch.clone(),
                    reference,
                    path: within,
                    entries,
                },
            )
        }
        // Nothing to walk into and nothing to read: a submodule's gitlink
        // is a path out of this repository rather than into it, and a path
        // git has never heard of is simply not there.
        Some(repositories::git::ObjectKind::Other) | None => {
            HttpResponse::NotFound().body(format!(
                "`{reference}:{within}` names nothing to browse in `{owner_name}/{repository_slug}`"
            ))
        }
    }
}

/// The tree address without a ref, which settles on the branch the
/// repository opens on: one redirect rather than a dead end for an address
/// typed that far. The same address written with a trailing slash is
/// settled the same way ([`get_tree_root_slash`]).
#[get("/~{user_or_team}/{repository_slug}/tree")]
async fn get_tree_root(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();
    tree_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        String::new(),
        String::new(),
    )
    .await
}

/// The same bare address with its trailing slash, which says no more than
/// no slash does.
#[get("/~{user_or_team}/{repository_slug}/tree/")]
async fn get_tree_root_slash(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();
    tree_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        String::new(),
        String::new(),
    )
    .await
}

/// One file as git holds it, served as its own bytes.
///
/// The same address as [`get_tree`] with `tree` read as `raw`: a revision,
/// then a path within it, and the answer is the file itself rather than a
/// page about it - what a `curl`, an editor or an `<img>` wants.
#[get("/~{user_or_team}/{repository_slug}/raw/{ref_or_commit}/{file_path:.*}")]
async fn get_raw(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, ref_or_commit, file_path) = path.into_inner();
    raw_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        ref_or_commit,
        file_path,
    )
    .await
}

/// The same address stopping at the revision: a revision with no path after
/// it is a directory's address, not a file's, and has no raw answer.
#[get("/~{user_or_team}/{repository_slug}/raw/{ref_or_commit}")]
async fn get_raw_revision(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, ref_or_commit) = path.into_inner();
    raw_address(
        data,
        viewer,
        owner_name,
        repository_slug,
        ref_or_commit,
        String::new(),
    )
    .await
}

/// What a raw address names, read as one address exactly the way
/// [`tree_address`] reads a tree one - the two are one address, after all -
/// and answered with the file's bytes rather than a page about it. An
/// address whose revision has no path after it names a directory, and a
/// directory has no bytes: that is a `404`, not a listing.
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        ref_or_commit = tracing::field::Empty,
        file_path = tracing::field::Empty,
        reference = tracing::field::Empty,
        within = tracing::field::Empty,
        bytes = tracing::field::Empty,
    )
)]
async fn raw_address(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    owner_name: String,
    repository_slug: String,
    ref_or_commit: String,
    file_path: String,
) -> HttpResponse {
    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    // What the address asked for, exactly as it was asked: the resolution
    // recorded beside it is what the forge understood by it.
    span.record("ref_or_commit", ref_or_commit.as_str());
    span.record("file_path", file_path.as_str());

    let (owner, repository, repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    // Read outside the lock, for the reason [`tree_address`] gives.
    let bare = RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // The two pieces the route split at are one address again here, for the
    // reason [`tree_address`] gives.
    let tail = joined_address(&ref_or_commit, &file_path);

    // There is no default file to settle on the way the tree address
    // settles on the default branch: an address whose revision has no path
    // after it names a directory, and a directory has no bytes.
    let (reference, within) = match resolve_address(&bare, &tail, &repository.default_branch).await
    {
        Address::Found { reference, within } if !within.is_empty() => (reference, within),
        Address::Found { .. } | Address::Unborn | Address::Missing => {
            return HttpResponse::NotFound().body(format!(
                "`{tail}` names no file of `{owner_name}/{repository_slug}`"
            ));
        }
    };
    span.record("reference", reference.as_str());
    span.record("within", within.as_str());

    let bytes = match repositories::git::blob(&bare, &format!("{reference}:{within}")).await {
        Ok(Some(bytes)) => bytes,
        Ok(None) => {
            return HttpResponse::NotFound().body(format!(
                "`{reference}:{within}` is not a file of `{owner_name}/{repository_slug}`"
            ));
        }
        Err(error) => {
            // The path was there a moment ago and git would not hand it
            // over: a fault, not an empty file, and saying nothing is
            // better than answering with bytes that are not the file's.
            error!(%error, path = %bare.display(), "reading a file failed");
            return HttpResponse::InternalServerError().finish();
        }
    };
    span.record("bytes", bytes.len());

    // The name says what the file is; `nosniff` says a browser may not
    // second-guess that into running something the file never was.
    let mime = mime_guess::from_path(within).first_or_octet_stream();
    let content_type = if mime.type_() == mime_guess::mime::TEXT {
        format!("{mime}; charset=utf-8")
    } else {
        mime.to_string()
    };

    HttpResponse::Ok()
        .insert_header((header::CONTENT_TYPE, content_type))
        .insert_header((header::X_CONTENT_TYPE_OPTIONS, "nosniff"))
        .body(bytes)
}

/// One file of one ref: its own page, in the one form the file suits -
/// rendered when it is markdown, coloured when it is source, and refused
/// when it is not text at all.
#[tracing::instrument(
    level = "debug",
    skip(bare, viewer, owner, repository),
    fields(bytes = tracing::field::Empty, display = tracing::field::Empty)
)]
async fn file_page(
    bare: &Path,
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    reference: &str,
    within: &str,
) -> HttpResponse {
    let spec = format!("{reference}:{within}");
    let bytes = match repositories::git::blob(bare, &spec).await {
        Ok(Some(bytes)) => bytes,
        Ok(None) => {
            return HttpResponse::NotFound().body(format!(
                "`{spec}` is not a file of `{}/{}`",
                owner.name, repository.slug
            ));
        }
        Err(error) => {
            // A file git named but would not hand over is a fault, not an
            // empty file: showing nothing would be a lie about what the
            // repository holds.
            error!(%error, path = %bare.display(), "reading a file failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let text = || String::from_utf8_lossy(&bytes);
    let (display, described) = if repositories::git::looks_binary(&bytes) {
        (views::FileDisplay::Binary, "binary")
    } else if is_markdown(within) {
        // `markdown::render` sanitizes what it returns, so the page embeds
        // it as it stands, the same as a readme.
        (
            views::FileDisplay::Rendered(markdown::render_with_repo_context(
                &text(),
                &owner.name,
                &repository.slug,
                reference,
                within,
            )),
            "rendered",
        )
    } else {
        // `highlight::render` escapes as it colours, for the same reason.
        (
            views::FileDisplay::Coloured(highlight::render(&text(), within)),
            "coloured",
        )
    };

    // Only a file that came out of git gets these: a page refusing to show
    // one says so by having read nothing, not by claiming zero bytes.
    let span = tracing::Span::current();
    span.record("bytes", bytes.len());
    span.record("display", described);

    views::file(
        viewer_name(viewer),
        &owner.name,
        &views::FileDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            reference: reference.to_string(),
            path: within.to_string(),
            display,
        },
    )
}

/// Whether a file is markdown the forge renders rather than shows.
fn is_markdown(path: &str) -> bool {
    Path::new(path)
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| {
            matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown")
        })
}

/// The address to resolve out of the two pieces the route splits it into.
///
/// The split is the route's reading rather than git's: a ref may hold
/// slashes of its own, so only the repository's own refs - asked about the
/// whole address, longest first - can say where a revision ends and its
/// path begins. The pieces are therefore put back together here before
/// anything resolves them, and a trailing slash on the path, which says no
/// more than no slash does, is dropped on the way.
fn joined_address(ref_or_commit: &str, file_path: &str) -> String {
    let file_path = file_path.trim_end_matches('/');
    if file_path.is_empty() {
        ref_or_commit.to_string()
    } else {
        format!("{ref_or_commit}/{file_path}")
    }
}

/// What a tree or raw address's tail turned out to name.
enum Address {
    /// A revision - a branch, a tag, a hash - and the path within it.
    Found { reference: String, within: String },
    /// The branch the repository opens on, which nothing has been pushed
    /// to yet: an empty answer rather than a missing one.
    Unborn,
    /// Nothing in the repository answers for the tail.
    Missing,
}

/// The revision and the path within it that a tail names.
///
/// The tree browser and the raw file address are two readings of one
/// address, so they read it the same way, here: [`resolve_tail`] longest
/// ref first, then git's own knowledge of where a revision in the tail
/// ends and its path begins, then the default branch of a repository with
/// nothing pushed to it, then nothing at all. A tail dressed up as an
/// option - one beginning with a dash - is nothing too, since nothing of
/// git's reading begins with one.
async fn resolve_address(bare: &Path, tail: &str, default_branch: &str) -> Address {
    if tail.starts_with('-') {
        return Address::Missing;
    }

    let available = match repositories::git::ref_names(bare).await {
        Ok(available) => available,
        Err(error) => {
            error!(%error, path = %bare.display(), "listing a repository's ref names failed");
            Vec::new()
        }
    };

    if let Some((reference, within)) = resolve_tail(tail, &available) {
        return Address::Found { reference, within };
    }

    // No ref names any part of it, but git may still know where the
    // revision ends and the path begins: a commit hash, `HEAD~3`. Every
    // point the tail could end at is asked, longest first, the same rule
    // the refs were read by - so `hash/src/lib.rs` is `lib.rs` at `hash`
    // once the prefixes that are not revisions have failed.
    let mut ends: Vec<usize> = tail.match_indices('/').map(|(at, _)| at).collect();
    ends.push(tail.len());
    for end in ends.into_iter().rev() {
        if end == 0 {
            continue;
        }

        let reaches = match repositories::git::resolves_to_commit(bare, &tail[..end]).await {
            Ok(reaches) => reaches,
            Err(error) => {
                error!(%error, path = %bare.display(), "resolving a revision failed");
                false
            }
        };

        if reaches {
            let within = if end == tail.len() {
                String::new()
            } else {
                tail[end + 1..].to_string()
            };
            return Address::Found {
                reference: tail[..end].to_string(),
                within,
            };
        }
    }

    if tail == default_branch {
        Address::Unborn
    } else {
        Address::Missing
    }
}

/// The ref a tree address names, and the directory left over as its path.
///
/// The tail is read longest-ref-first: `feature/x/pkg` is the branch
/// `feature/x` with `pkg` beneath it, not the branch `feature` with `x/pkg`
/// beneath that, when a repository's packed refs hold both. `None` when no
/// ref names any prefix of the tail.
fn resolve_tail(tail: &str, available: &[String]) -> Option<(String, String)> {
    // Every point a ref could end at: each slash, and the tail itself.
    let mut ends: Vec<usize> = tail.match_indices('/').map(|(at, _)| at).collect();
    ends.push(tail.len());

    for end in ends.into_iter().rev() {
        if !available.iter().any(|name| name == &tail[..end]) {
            continue;
        }
        let path = if end == tail.len() {
            ""
        } else {
            &tail[end + 1..]
        };
        return Some((tail[..end].to_string(), path.to_string()));
    }

    None
}

/// A ref as its listing shows it.
fn ref_summary(reference: repositories::git::Ref) -> views::RefSummary {
    views::RefSummary {
        name: reference.name,
        hash: reference.commit.hash,
        full_hash: reference.commit.full_hash,
        author: reference.commit.author,
        subject: reference.commit.subject,
    }
}

/// Splits a compare range like "main...feat" into its base and head components.
///
/// Only accepts three-dot syntax (`base...head`). Returns `None` for two-dot
/// syntax or missing separator. Both sides must be non-empty.
fn split_compare_range(tail: &str) -> Option<(&str, &str)> {
    let (base, head) = tail.split_once("...")?;
    if base.is_empty() || head.is_empty() {
        None
    } else {
        Some((base, head))
    }
}

/// Validates that a ref name is safe to use as a compare endpoint.
///
/// Rejects empty strings, refs starting with `-` (git argument injection),
/// and refs containing `..` (which would be ambiguous with range syntax).
fn usable_compare_end(end: &str) -> bool {
    !end.is_empty() && !end.starts_with('-') && !end.contains("..")
}

/// The read-only clone URL for a repository reached at this request's host.
///
/// Built from the request rather than from a configured setting, because the
/// web server's own bind address is often a loopback or wildcard one that says
/// nothing about the name a client dialled it by.
fn http_clone_url(req: &HttpRequest, owner: &str, slug: &str) -> String {
    let connection = req.connection_info();
    format!(
        "{}://{}/{owner}/{slug}.git",
        connection.scheme(),
        connection.host()
    )
}

/// The languages a request asks for, most wanted first.
///
/// Just enough of `Accept-Language` to choose a readme: a comma-separated list
/// of tags, each with an optional `;q=` quality, ordered by quality and then
/// by the order they were written. A wildcard is dropped - it asks for
/// anything, which is no preference - and a tag with a quality of zero is a
/// refusal rather than a request.
pub(crate) fn accept_languages(header: Option<&str>) -> Vec<String> {
    let Some(header) = header else {
        return Vec::new();
    };

    let mut weighted = Vec::new();
    for (position, part) in header.split(',').enumerate() {
        let mut parameters = part.split(';');
        let Some(tag) = parameters.next() else {
            continue;
        };
        let tag = tag.trim();
        if tag.is_empty() || tag == "*" {
            continue;
        }

        let mut quality = 1.0_f32;
        for parameter in parameters {
            if let Some(value) = parameter.trim().strip_prefix("q=")
                && let Ok(parsed) = value.trim().parse::<f32>()
            {
                quality = parsed;
            }
        }
        if quality <= 0.0 {
            continue;
        }

        weighted.push((quality, position, tag.to_string()));
    }

    // The sort is stable, so equal qualities keep the order they were written.
    weighted.sort_by(|left, right| {
        right
            .0
            .partial_cmp(&left.0)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let mut languages = Vec::new();
    for (_, _, tag) in weighted {
        if !languages.contains(&tag) {
            languages.push(tag);
        }
    }

    languages
}

/// The repositories shown on `name`'s profile, newest first.
///
/// `viewer` is `None` for somebody signed out. A private repository is left
/// out entirely - not shown as "private" but omitted - so that the profile
/// does not tell a stranger what it cannot show them.
pub(crate) async fn summaries(
    db: &DatabaseConnection,
    owner_id: i64,
    owner_name: &str,
    viewer: Option<i64>,
) -> Result<Vec<RepositorySummary>, sea_orm::DbErr> {
    let repositories = store::list_repositories(db, owner_id).await?;
    Ok(repositories
        .into_iter()
        .filter(|repository| {
            repositories::may_read(repository.visibility, repository.owner_id, viewer)
        })
        .map(|repository| RepositorySummary {
            owner_name: owner_name.to_string(),
            slug: repository.slug,
            description: repository.description,
            visibility: repository.visibility.label(),
        })
        .collect())
}

#[cfg(test)]
mod tests {
    use super::{
        accept_languages, is_markdown, resolve_tail, split_compare_range, usable_compare_end,
    };

    fn available(names: &[&str]) -> Vec<String> {
        names.iter().map(|name| name.to_string()).collect()
    }

    #[test]
    fn a_tail_resolves_against_the_refs_it_names() {
        let refs = available(&["main", "feature/x", "v1"]);

        assert_eq!(
            resolve_tail("main", &refs),
            Some(("main".to_string(), String::new()))
        );
        assert_eq!(
            resolve_tail("main/src/deep", &refs),
            Some(("main".to_string(), "src/deep".to_string()))
        );
        // The whole tail is one ref, even though `feature` on its own would
        // also have been a prefix of it.
        assert_eq!(
            resolve_tail("feature/x", &refs),
            Some(("feature/x".to_string(), String::new()))
        );
        assert_eq!(
            resolve_tail("feature/x/pkg", &refs),
            Some(("feature/x".to_string(), "pkg".to_string()))
        );
    }

    #[test]
    fn a_tail_no_ref_answers_for_is_not_resolvable() {
        let refs = available(&["main", "feature/x"]);

        assert_eq!(resolve_tail("nope", &refs), None);
        assert_eq!(resolve_tail("nope/deeper", &refs), None);
        // `main` is a ref, but nothing makes `maint` one.
        assert_eq!(resolve_tail("maint", &refs), None);
    }

    #[test]
    fn the_longest_ref_holding_another_one_wins() {
        // A repository's refs cannot hold both as loose files, but packed
        // ones can, and the answer has to be the same either way: the ref
        // that says the most of the tail is the one meant.
        let refs = available(&["feature", "feature/x"]);

        assert_eq!(
            resolve_tail("feature/x/y", &refs),
            Some(("feature/x".to_string(), "y".to_string()))
        );
        assert_eq!(
            resolve_tail("feature/y", &refs),
            Some(("feature".to_string(), "y".to_string()))
        );
    }

    #[test]
    fn markdown_is_recognised_by_the_file_name() {
        assert!(is_markdown("README.md"));
        assert!(is_markdown("docs/guide.markdown"));
        // The extension is a file's own, whatever case it was written in.
        assert!(is_markdown("RELEASES.MD"));
        // A file that merely looks long enough to be one, and a directory
        // whose name ends like a file's, are not.
        assert!(!is_markdown("mr.d"));
        assert!(!is_markdown("md"));
    }

    #[test]
    fn no_header_is_no_preference() {
        assert!(accept_languages(None).is_empty());
        assert!(accept_languages(Some("")).is_empty());
        assert!(accept_languages(Some("   ")).is_empty());
    }

    #[test]
    fn languages_are_ordered_by_quality_then_as_written() {
        assert_eq!(
            accept_languages(Some("en;q=0.5, fr, de;q=0.8")),
            ["fr", "de", "en"]
        );
        // Equal qualities keep the order they were written in.
        assert_eq!(accept_languages(Some("fr, en")), ["fr", "en"]);
    }

    #[test]
    fn a_wildcard_and_a_refusal_are_not_preferences() {
        assert!(accept_languages(Some("*")).is_empty());
        assert_eq!(accept_languages(Some("*, fr")), ["fr"]);
        assert_eq!(accept_languages(Some("fr;q=0, en")), ["en"]);
    }

    #[test]
    fn repeated_languages_appear_once() {
        assert_eq!(
            accept_languages(Some("fr, fr-CA;q=0.8, fr")),
            ["fr", "fr-CA"]
        );
    }

    #[test]
    fn a_malformed_quality_is_ignored() {
        // No quality is a request for it; a `q` that is not a number leaves the
        // request as the default rather than dropping the language.
        assert_eq!(accept_languages(Some("fr;q=bob")), ["fr"]);
    }

    #[test]
    fn split_compare_range_parses_three_dot_syntax() {
        assert_eq!(split_compare_range("main...feat"), Some(("main", "feat")));
        assert_eq!(
            split_compare_range("feature/x...release/1.0"),
            Some(("feature/x", "release/1.0"))
        );
        assert_eq!(split_compare_range("v1.0...v2.0"), Some(("v1.0", "v2.0")));
        // Edge case: a....b splits at first ..., leaving .b as head
        assert_eq!(split_compare_range("a....b"), Some(("a", ".b")));
    }

    #[test]
    fn split_compare_range_rejects_two_dots() {
        assert_eq!(split_compare_range("main..feat"), None);
    }

    #[test]
    fn split_compare_range_rejects_missing_separator() {
        assert_eq!(split_compare_range("main"), None);
    }

    #[test]
    fn split_compare_range_rejects_empty_sides() {
        assert_eq!(split_compare_range("main..."), None);
        assert_eq!(split_compare_range("...feat"), None);
    }

    #[test]
    fn usable_compare_end_accepts_valid_refs() {
        assert!(usable_compare_end("main"));
        assert!(usable_compare_end("feature/x"));
        assert!(usable_compare_end("v1.0"));
    }

    #[test]
    fn usable_compare_end_rejects_empty() {
        assert!(!usable_compare_end(""));
    }

    #[test]
    fn usable_compare_end_rejects_leading_dash() {
        assert!(!usable_compare_end("-HEAD"));
        assert!(!usable_compare_end("-main"));
    }

    #[test]
    fn usable_compare_end_rejects_double_dots() {
        assert!(!usable_compare_end("main..feat"));
        assert!(!usable_compare_end("a..b"));
    }
}