a73x

src/server/http/repo/mod.rs

Ref:   Size: 5.1 KiB

mod overview;
mod commits;
mod tree;
mod diff;
mod patches;
mod issues;
mod readme;

pub use overview::overview;
pub use commits::{commits, commits_ref};
pub use tree::{tree_root, tree, blob};
pub use diff::diff;
pub use patches::{patches, patch_detail};
pub use issues::{issues, issue_detail};

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use chrono::{TimeZone, Utc};

use super::AppState;

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "error.html")]
pub struct ErrorTemplate {
    pub site_title: String,
    pub status_code: u16,
    pub message: String,
}

fn not_found(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 404,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::NOT_FOUND;
    response
}

fn internal_error(state: &AppState, message: impl Into<String>) -> Response {
    let mut response = ErrorTemplate {
        site_title: state.site_title.clone(),
        status_code: 500,
        message: message.into(),
    }
    .into_response();
    *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
    response
}

/// Count open patches and issues for sidebar badges.
/// Note: this does a full state read on each call. For repos with many collab
/// objects this could be slow — consider caching with a short TTL if needed.
fn collab_counts(repo: &git2::Repository) -> (usize, usize) {
    let open_patches = git_collab::state::list_patches(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|p| p.status == git_collab::state::PatchStatus::Open)
        .count();

    let open_issues = git_collab::state::list_issues(repo)
        .unwrap_or_default()
        .into_iter()
        .filter(|i| i.status == git_collab::state::IssueStatus::Open)
        .count();

    (open_patches, open_issues)
}

#[derive(Debug)]
pub struct OverviewCommit {
    pub id: String,
    pub short_id: String,
    pub summary: String,
    pub author: String,
    pub date: String,
}

fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> {
    let mut revwalk = match repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return Vec::new(),
    };

    // Try HEAD first; if unborn, fall back to the first local branch
    if revwalk.push_head().is_err() {
        let branch = list_branches(repo).into_iter().next();
        let pushed = branch.and_then(|name| {
            let obj = repo.revparse_single(&name).ok()?;
            revwalk.push(obj.id()).ok()
        });
        if pushed.is_none() {
            return Vec::new();
        }
    }

    revwalk
        .take(limit)
        .filter_map(|oid| {
            let oid = oid.ok()?;
            let commit = repo.find_commit(oid).ok()?;
            let id = oid.to_string();
            let short_id = id[..8.min(id.len())].to_string();
            let summary = commit.summary().unwrap_or("").to_string();
            let author = commit.author().name().unwrap_or("").to_string();
            let secs = commit.time().seconds();
            let date = Utc
                .timestamp_opt(secs, 0)
                .single()
                .map(|dt| dt.format("%Y-%m-%d").to_string())
                .unwrap_or_default();
            Some(OverviewCommit { id, short_id, summary, author, date })
        })
        .collect()
}

#[derive(Debug)]
pub struct CommentView {
    pub author: String,
    pub body: String,
    pub timestamp: String,
}

/// List local branch names, sorted alphabetically.
fn list_branches(repo: &git2::Repository) -> Vec<String> {
    let mut names: Vec<String> = repo
        .branches(Some(git2::BranchType::Local))
        .into_iter()
        .flatten()
        .filter_map(|b| {
            let (branch, _) = b.ok()?;
            branch.name().ok()?.map(|n| n.to_string())
        })
        .collect();
    names.sort();
    names
}

/// Resolve HEAD to its short branch name (e.g. "main").
/// Falls back to the first existing branch if HEAD points to an unborn branch,
/// which happens when a bare repo is init'd with master but only main is pushed.
fn head_branch_name(repo: &git2::Repository) -> String {
    if let Ok(head) = repo.head() {
        if let Some(name) = head.shorthand() {
            return name.to_string();
        }
    }
    // HEAD is unborn or detached — pick the first local branch
    list_branches(repo).into_iter().next().unwrap_or_else(|| "HEAD".to_string())
}

/// Open a repo by name, returning the entry and git2::Repository or an error Response.
#[allow(clippy::result_large_err)]
fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> {
    let entry = match crate::repos::resolve(&state.repos_dir, repo_name) {
        Some(e) => e,
        None => return Err(not_found(state, format!("Repository '{}' not found.", repo_name))),
    };

    let repo = match crate::repos::open(&entry) {
        Ok(r) => r,
        Err(_) => return Err(internal_error(state, "Failed to open repository.")),
    };

    Ok((entry, repo))
}