//! Page: one commit at `/{owner}/{repo}/commit/{oid}`: metadata, file list and unified diff. use forge_core::tz::format_in_tz; use forge_git::browse; use forge_git::history; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, view}; use crate::i18n::code::{self as g, K}; use crate::i18n::{self}; use crate::pages::commits::{SigBadgeView, blocking, iso_time, now_secs, short_oid}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::render::emoji::emojify; use crate::signatures; use crate::state::AppState; use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary}; use crate::views::diff::{DiffLang, LIMITS, diff_section}; use crate::views::relative_time; use crate::views::shell; path_param!(owner); path_param!(repo); path_param!(oid); /// Author, committer or parent line data. struct Person { name: String, relative: String, absolute: String, iso: String, } struct ParentLink { href: String, short: String, } fn is_hex_prefix(s: &str) -> bool { (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit()) } #[page("/{owner}/{repo}/commit/{oid}")] async fn commit_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let rev = path_param::(cx); if !is_hex_prefix(rev) { return Err(not_found().into()); } let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let repo_dir = ctx.path.clone(); let rev = rev.to_owned(); let Some(oid) = blocking(move || browse::resolve_commit(&repo_dir, &rev)).await? else { return Err(not_found().into()); }; let repo_dir = ctx.path.clone(); let lookup = oid.clone(); let Some(info) = blocking(move || browse::read_commit(&repo_dir, &lookup)).await? else { return Err(not_found().into()); }; let diff = history::commit_diff(&state.git, &ctx.path, &info.oid, LIMITS).await?; let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let tz = ctx.viewer_tz().to_owned(); let parent = info.parents.first().cloned(); let now = now_secs(); let author = Person { name: info.author_name.clone(), relative: relative_time(lang, info.author_time, now), absolute: format_in_tz(info.author_time, &tz), iso: iso_time(info.author_time), }; let committer_differs = info.committer_name != info.author_name || info.committer_email != info.author_email; let committer = Person { name: info.committer_name.clone(), relative: relative_time(lang, info.committer_time, now), absolute: format_in_tz(info.committer_time, &tz), iso: iso_time(info.committer_time), }; let parents: Vec = info .parents .iter() .map(|p| ParentLink { href: format!("/{owner_name}/{repo_name}/commit/{p}"), short: short_oid(p).to_owned(), }) .collect(); let is_root = parents.is_empty(); let has_body = !info.message.trim().is_empty(); let title = format!("{owner_name}/{repo_name}"); let repo_href = format!("/{owner_name}/{repo_name}"); let tree_href = format!("/{owner_name}/{repo_name}/tree/{}", info.oid); let summary = emojify(&info.summary).into_owned(); let full_oid = info.oid.clone(); let message = emojify(&info.message).into_owned(); let statuses = state.store.statuses_for(&ctx.repo.id, &full_oid).await?; let checks = (!statuses.is_empty()).then(|| StatusSummary::from_rows(&statuses)); let sig = signatures::badges(state, &ctx.repo, std::slice::from_ref(&full_oid)) .await .get(&full_oid) .map(|badge| SigBadgeView::from_badge(badge, lang)); let header = ctx.head(state, "commits").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(summary.as_str())

(title.as_str())

(full_oid.as_str()) if let Some(badge) = sig { (badge.label) }

if let Some(summary) = checks { checks_summary(summary: summary, lang: StatusLang(lang)) }

(g::t(lang, K::AuthorLabel)) (author.name.as_str()) " · "

if committer_differs {

(g::t(lang, K::CommitterLabel)) (committer.name.as_str()) " · "

} if is_root {

(g::t(lang, K::RootCommit))

} else {

(g::t(lang, K::ParentLabel)) for p in parents.iter() { (p.short.as_str()) " " }

}

(g::t(lang, K::BrowseFiles))

if has_body {
(message.as_str())
} diff_section(diff: &diff, owner: owner_name.as_str(), repo: repo_name.as_str(), oid: info.oid.as_str(), parent: parent.as_deref(), subject: g::t(lang, K::ThisCommit), lang: DiffLang(lang)) ) }) } /// Other methods on a commit path get a plain 404. #[route(* "/{owner}/{repo}/commit/{oid}")] async fn commit_other_methods() -> Result<&'static str> { Err(not_found().into()) } #[cfg(test)] mod tests { use forge_core::Visibility; use scraper::{Html, Selector}; use super::is_hex_prefix; use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get}; fn doc(body: &[u8]) -> Html { Html::parse_document(std::str::from_utf8(body).unwrap()) } /// Text of each cell in every `tr` matching `row_selector`, as `[old, new, code]`. fn rows(html: &Html, row_selector: &str) -> Vec> { let tr = Selector::parse(row_selector).unwrap(); let td = Selector::parse("td").unwrap(); html.select(&tr) .map(|row| { row.select(&td) .map(|c| c.text().collect::().trim().to_owned()) .collect() }) .collect() } #[test] fn hex_prefix_bounds() { assert!(is_hex_prefix("abcd")); assert!(is_hex_prefix(&"a".repeat(64))); assert!(!is_hex_prefix("abc")); assert!(!is_hex_prefix(&"a".repeat(65))); assert!(!is_hex_prefix("zzzz")); } #[tokio::test] async fn modified_and_added_files_have_line_numbers() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get( &fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.modified), ) .await; assert_eq!(status, 200); let html = doc(&body); let rows = rows(&html, "tr.diff-line"); let find = |code: &str| { rows.iter() .find(|r| r.get(2).map(String::as_str) == Some(code)) .unwrap_or_else(|| panic!("no row {code:?} in {rows:?}")) .clone() }; assert_eq!(find("-two"), vec!["2", "", "-two"]); assert_eq!(find("+TWO"), vec!["", "2", "+TWO"]); assert_eq!(find("three"), vec!["3", "3", "three"]); assert_eq!(find("+four"), vec!["", "4", "+four"]); assert_eq!(find("+new file"), vec!["", "1", "+new file"]); let added = Selector::parse(".diff-file-header").unwrap(); let headers: Vec = html .select(&added) .map(|h| h.text().collect::()) .collect(); assert!( headers .iter() .any(|h| h.contains("new.txt") && h.contains("added")) ); assert!( headers .iter() .any(|h| h.contains("a.txt") && h.contains("modified")) ); let text = String::from_utf8_lossy(&body).into_owned(); assert!(text.contains("Body line one.")); assert!(text.contains("class=\"mono\"")); assert!(text.contains("diff-file-body")); } #[tokio::test] async fn root_commit_page_renders() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.root)).await; assert_eq!(status, 200); let html = String::from_utf8(body.clone()).unwrap(); assert!(html.contains("Root commit (no parents)")); let rows = rows(&doc(&body), "tr.diff-line"); assert!( rows.iter() .any(|r| r.get(2).map(String::as_str) == Some("+one")) ); } #[tokio::test] async fn commit_title_uses_viewer_timezone() { use topcoat::router::request::Request; use topcoat::router::{Body, to_bytes}; use crate::auth::SESSION_COOKIE; let fx = Fixture::new(Visibility::Public).await; let tokyo = fx .store .create_user("tokyo", None, "not-a-real-hash", false) .await .unwrap(); fx.store .set_profile(&tokyo.id, "", "Asia/Tokyo") .await .unwrap(); crate::test_support::create_session(&fx.store, &tokyo.id, "tokyo-token", "tokyo-csrf") .await; let uri = format!("/{OWNER}/{REPO}/commit/{}", fx.root); let req = Request::builder() .method("GET") .uri(&uri) .header("cookie", format!("__Host-{SESSION_COOKIE}=tokyo-token")) .body(Body::empty()) .unwrap(); let res = fx.router.handle(req).await; assert_eq!(res.status(), 200); let html = String::from_utf8( to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(), ) .unwrap(); // Scope to the page's own `
`: the shell's theme button has a `title` with ':'. let main = html .split_once("")) .map(|(main, _)| main) .expect("page has a
element"); let titles: Vec<&str> = main .split("title=\"") .skip(1) .filter_map(|rest| rest.split('"').next()) .filter(|t| t.contains(':')) .collect(); assert!(!titles.is_empty(), "absolute time in title: {html}"); assert!( titles.iter().all(|t| t.ends_with("+09")), "titles in Tokyo time: {titles:?}" ); // Anonymous viewers see UTC, so no +09 offset. let (status, _, body) = get(&fx.router, &uri).await; assert_eq!(status, 200); let anon = String::from_utf8(body).unwrap(); assert!(!anon.contains("+09\""), "anonymous view is not Tokyo time"); } #[tokio::test] async fn bad_oid_is_404() { let fx = Fixture::new(Visibility::Public).await; let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/zzzz")).await; assert_eq!(status, 404); let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/abc")).await; assert_eq!(status, 404); let unknown = "0123456789abcdef0123456789abcdef01234567"; let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{unknown}")).await; assert_eq!(status, 404); } #[tokio::test] async fn unsigned_commit_page_has_no_signature_badge() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get( &fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.modified), ) .await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(!html.contains("sig-badge")); } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let fx = Fixture::new(Visibility::Private).await; let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.root)).await; assert_eq!(status, 404); } }