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