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};
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    is_default: bool,
34}
35
36/// Template context for the branch list page.
37#[derive(Template, WebTemplate)]
38#[template(path = "branch_list.html")]
39struct BranchList {
40    repo: String,
41    branches: Vec<BranchSummary>,
42}
43
44/// Template context for a single commit row in the log.
45struct CommitSummary {
46    sha: String,
47    message: String,
48    author: String,
49    date: String,
50}
51
52/// Template context for the branch detail (log) page.
53#[derive(Template, WebTemplate)]
54#[template(path = "branch_detail.html")]
55struct BranchDetail {
56    repo: String,
57    branch: String,
58    commits: Vec<CommitSummary>,
59    page: usize,
60    has_prev: bool,
61    has_next: bool,
62}
63
64/// Renders the full list of branches for a repository.
65pub async fn list(
66    State(state): State<AppState>,
67    RepoName(repo): RepoName,
68) -> Result<impl IntoResponse> {
69    let git_repo =
70        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
71
72    let head_name = git_repo.head_name().ok().flatten();
73
74    // `.local_branches()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.
75    // The first `.flatten()` unwraps the outer `Result`, the second unwraps
76    // each `Result<Reference>`, silently discarding any errors.
77    let branches = git_repo
78        .references()
79        .map_err(|e| Error::GitCorrupt(e.to_string()))?
80        .local_branches()
81        .into_iter()
82        .flatten()
83        .flatten()
84        .filter_map(|mut branch| {
85            let commit = branch.peel_to_commit().ok()?;
86            let date = commit_date(&commit)?;
87            let name = branch.name().shorten().to_string();
88            let is_default = head_name
89                .as_ref()
90                .is_some_and(|h| h.as_ref() == branch.name());
91
92            Some(BranchSummary {
93                name,
94                date,
95                is_default,
96            })
97        })
98        .collect();
99
100    Ok(BranchList { repo, branches })
101}
102
103/// Renders a paginated log of commits reachable from the tip of the given branch.
104pub async fn detail(
105    State(state): State<AppState>,
106    RepoName(repo): RepoName,
107    Path(params): Path<BranchPath>,
108    Query(pagination): Query<Pagination>,
109) -> Result<impl IntoResponse> {
110    let branch = params.branch;
111    let page = pagination.page;
112
113    let git_repo =
114        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
115
116    let ref_name = format!("refs/heads/{branch}");
117    let tip = git_repo
118        .find_reference(&ref_name)
119        .map_err(|_| Error::NotFound(format!("branch {branch}")))?
120        .peel_to_commit()
121        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
122
123    // Fetch one extra commit to determine if a next page exists.
124    let mut commits: Vec<CommitSummary> = tip
125        .id()
126        .ancestors()
127        .all()
128        .map_err(|e| Error::GitCorrupt(e.to_string()))?
129        .filter_map(|info| {
130            let info = info.ok()?;
131            let commit = info.id().object().ok()?.try_into_commit().ok()?;
132
133            let sha = info.id().to_string();
134            let message = commit.message().ok()?.summary().to_string();
135            let author = commit.author().ok()?.name.to_string();
136            let date = commit_date(&commit)?;
137
138            Some(CommitSummary {
139                sha,
140                message,
141                author,
142                date,
143            })
144        })
145        .skip(page * PAGE_SIZE)
146        .take(PAGE_SIZE + 1)
147        .collect();
148
149    // The extra commit was only fetched to detect whether a next page exists.
150    // Discard it before rendering.
151    let has_next = commits.len() > PAGE_SIZE;
152    if has_next {
153        commits.pop();
154    }
155
156    Ok(BranchDetail {
157        repo,
158        branch,
159        commits,
160        page,
161        has_prev: page > 0,
162        has_next,
163    })
164}