Parent [>]
1//! Route handlers for branch pages.
2//!
3//! Covers two routes: the branch list (`/{repo}/branches`) and the branch
4//! detail page (`/{repo}/branches/{*branch}`).  The detail handler resolves
5//! the branch ref to its tip commit and walks ancestors to produce a log view.
6
7use askama::Template;
8use askama_web::WebTemplate;
9use axum::extract::{Path, Query, State};
10use axum::response::IntoResponse;
11use serde::Deserialize;
12
13use crate::error::{Error, Result};
14use crate::routes::{AppState, RepoName, commit_date, commit_timestamp};
15
16const PAGE_SIZE: usize = 100;
17
18#[derive(Deserialize)]
19pub(super) struct BranchPath {
20    branch: String,
21}
22
23#[derive(Deserialize)]
24pub(super) struct Pagination {
25    #[serde(default)]
26    page: usize,
27}
28
29/// Template context for a single branch in the list.
30struct BranchSummary {
31    name: String,
32    date: String,
33    timestamp: i64,
34    is_default: bool,
35}
36
37/// Template context for the branch list page.
38#[derive(Template, WebTemplate)]
39#[template(path = "branch_list.html")]
40struct BranchList {
41    repo: String,
42    branches: Vec<BranchSummary>,
43}
44
45/// Template context for a single commit row in the log.
46struct CommitSummary {
47    sha: String,
48    message: String,
49    author: String,
50    date: String,
51}
52
53/// Template context for the branch detail (log) page.
54#[derive(Template, WebTemplate)]
55#[template(path = "branch_detail.html")]
56struct BranchDetail {
57    repo: String,
58    branch: String,
59    tip_sha: String,
60    commits: Vec<CommitSummary>,
61    page: usize,
62    has_prev: bool,
63    has_next: bool,
64}
65
66/// Renders the full list of branches for a repository.
67pub async fn list(
68    State(state): State<AppState>,
69    RepoName(repo): RepoName,
70) -> Result<impl IntoResponse> {
71    let git_repo =
72        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
73
74    let head_name = git_repo.head_name().ok().flatten();
75
76    // `.local_branches()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.
77    // The first `.flatten()` unwraps the outer `Result`, the second unwraps
78    // each `Result<Reference>`, silently discarding any errors.
79    let mut branches: Vec<BranchSummary> = git_repo
80        .references()
81        .map_err(|e| Error::GitCorrupt(e.to_string()))?
82        .local_branches()
83        .into_iter()
84        .flatten()
85        .flatten()
86        .filter_map(|mut branch| {
87            let commit = branch.peel_to_commit().ok()?;
88            let date = commit_date(&commit)?;
89            let timestamp = commit_timestamp(&commit)?;
90            let name = branch.name().shorten().to_string();
91            let is_default = head_name
92                .as_ref()
93                .is_some_and(|h| h.as_ref() == branch.name());
94
95            Some(BranchSummary {
96                name,
97                date,
98                timestamp,
99                is_default,
100            })
101        })
102        .collect();
103
104    branches.sort_by(|a, b| {
105        b.is_default
106            .cmp(&a.is_default)
107            .then(b.timestamp.cmp(&a.timestamp))
108    });
109
110    Ok(BranchList { repo, branches })
111}
112
113/// Renders a paginated log of commits reachable from the tip of the given branch.
114pub async fn detail(
115    State(state): State<AppState>,
116    RepoName(repo): RepoName,
117    Path(params): Path<BranchPath>,
118    Query(pagination): Query<Pagination>,
119) -> Result<impl IntoResponse> {
120    let branch = params.branch;
121    let page = pagination.page;
122
123    let git_repo =
124        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
125
126    let ref_name = format!("refs/heads/{branch}");
127    let tip = git_repo
128        .find_reference(&ref_name)
129        .map_err(|_| Error::NotFound(format!("branch {branch}")))?
130        .peel_to_commit()
131        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
132
133    let tip_sha = tip.id().to_string();
134
135    // Fetch one extra commit to determine if a next page exists.
136    let mut commits: Vec<CommitSummary> = tip
137        .id()
138        .ancestors()
139        .all()
140        .map_err(|e| Error::GitCorrupt(e.to_string()))?
141        .filter_map(|info| {
142            let info = info.ok()?;
143            let commit = info.id().object().ok()?.try_into_commit().ok()?;
144
145            let sha = info.id().to_string();
146            let message = commit.message().ok()?.summary().to_string();
147            let author = commit.author().ok()?.name.to_string();
148            let date = commit_date(&commit)?;
149
150            Some(CommitSummary {
151                sha,
152                message,
153                author,
154                date,
155            })
156        })
157        .skip(page * PAGE_SIZE)
158        .take(PAGE_SIZE + 1)
159        .collect();
160
161    // The extra commit was only fetched to detect whether a next page exists.
162    // Discard it before rendering.
163    let has_next = commits.len() > PAGE_SIZE;
164    if has_next {
165        commits.pop();
166    }
167
168    Ok(BranchDetail {
169        repo,
170        branch,
171        tip_sha,
172        commits,
173        page,
174        has_prev: page > 0,
175        has_next,
176    })
177}