//! Page: commit history for a ref and optional path, at `/{owner}/{repo}/commits/{*rest}`. //! Also home of the helpers shared by the commit and archive pages. use std::time::{SystemTime, UNIX_EPOCH}; use std::collections::HashMap; use forge_core::store::commit_status::CommitStatus; use forge_core::tz::format_in_tz; use forge_git::browse::{self, CommitInfo}; use forge_git::history; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::{bad_request, not_found}; use topcoat::router::{page, path_param, query_params}; use topcoat::view::{View, view}; use crate::i18n::code::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::render::emoji::emojify; use crate::signatures::{self, SigBadge}; use crate::state::AppState; use crate::views::commit_status::{StatusLang, StatusSummary, commit_status}; use crate::views::shell; path_param!(owner); path_param!(repo); path_param!(*rest); /// Commits per history page. pub(crate) const PAGE_SIZE: usize = 50; /// Highest accepted `?page=` value. const MAX_PAGE: u32 = 1000; #[query_params(error = bad_request)] struct CommitsQuery { page: Option, } /// Runs a blocking `browse::*` call off the async runtime. pub(crate) async fn blocking(f: F) -> Result where F: FnOnce() -> forge_git::Result + Send + 'static, T: Send + 'static, { Ok(tokio::task::spawn_blocking(f).await??) } /// Current Unix time in seconds. pub(crate) fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } /// Days since the Unix epoch to a proleptic Gregorian `(year, month, day)`. fn civil_from_days(days: i64) -> (i64, u32, u32) { let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = yoe + era * 400 + i64::from(month <= 2); (year, month as u32, day as u32) } /// RFC 3339 UTC stamp for the `datetime` attribute. pub(crate) fn iso_time(at: i64) -> String { let (y, m, d) = civil_from_days(at.div_euclid(86_400)); let secs = at.rem_euclid(86_400); format!( "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60 ) } /// Percent-encodes each byte outside the unreserved set, keeping `/` as the separator. pub(crate) fn url_path(path: &str) -> String { let mut out = String::with_capacity(path.len()); for b in path.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { out.push(char::from(b)); } _ => out.push_str(&format!("%{b:02X}")), } } out } /// Seven-character abbreviation, as shown in lists. pub(crate) fn short_oid(oid: &str) -> &str { oid.get(..7).unwrap_or(oid) } /// One commit as shown in the history list. pub(crate) struct CommitRow { pub summary: String, pub author: String, pub commit_href: String, pub tree_href: String, pub short: String, pub relative: String, pub absolute: String, pub iso: String, /// Commit status icon and dropdown. Empty unless `with_statuses` attached some. pub status: StatusSummary, /// Signature badge. `None` for unsigned commits and commits not checked on this page. pub sig: Option, } /// A signature badge prepared for display: the CSS classes, the visible label and the title. #[derive(Clone)] pub(crate) struct SigBadgeView { pub class: &'static str, pub label: &'static str, pub title: String, } impl SigBadgeView { pub(crate) fn from_badge(badge: &SigBadge, lang: Lang) -> Self { match badge { SigBadge::Verified { signer, key } => Self { class: "sig-badge sig-verified", label: g::t(lang, K::SigVerified), title: g::signed_by(lang, signer, key), }, SigBadge::Unverified { reason } => Self { class: "sig-badge sig-unverified", label: g::t(lang, K::SigUnverified), title: reason.clone(), }, } } } impl CommitRow { pub(crate) fn new( owner: &str, repo: &str, info: &CommitInfo, now: i64, tz: &str, lang: Lang, ) -> Self { Self { summary: emojify(&info.summary).into_owned(), author: info.author_name.clone(), commit_href: format!("/{owner}/{repo}/commit/{}", info.oid), tree_href: format!("/{owner}/{repo}/tree/{}", info.oid), short: short_oid(&info.oid).to_owned(), relative: crate::views::relative_time(lang, info.author_time, now), absolute: format_in_tz(info.author_time, tz), iso: iso_time(info.author_time), status: StatusSummary::default(), sig: None, } } /// Attaches the signature badge of `oid` from the batched `signatures::badges` lookup. pub(crate) fn with_sig( mut self, badges: &HashMap, oid: &str, lang: Lang, ) -> Self { self.sig = badges .get(oid) .map(|badge| SigBadgeView::from_badge(badge, lang)); self } /// Attaches the statuses of `oid` from a batched lookup (see `statuses_for_many_rows`). pub(crate) fn with_statuses( mut self, statuses: &HashMap>, oid: &str, ) -> Self { self.status = statuses .get(oid) .map(|rows| StatusSummary::from_rows(rows)) .unwrap_or_default(); self } } #[page("/{owner}/{repo}/commits/{*rest}")] async fn commits_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let rest = path_param::(cx).collect::>().join("/"); let query = query_params::(cx)?; let page_no = query.page.unwrap_or(1); if !(1..=MAX_PAGE).contains(&page_no) { return Err(bad_request("page must be between 1 and 1000").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 Some((ref_name, commit, path)) = blocking(move || browse::split_ref_and_path(&repo_dir, &rest)).await? else { return Err(not_found().into()); }; let filter = (!path.is_empty()).then(|| path.clone()); let skip = (page_no as usize - 1) * PAGE_SIZE; let mut commits = history::log( &state.git, &ctx.path, &commit, filter.as_deref(), skip, PAGE_SIZE + 1, ) .await?; let has_older = commits.len() > PAGE_SIZE; commits.truncate(PAGE_SIZE); let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let spec = match &filter { Some(p) => format!("{ref_name}/{p}"), None => ref_name.clone(), }; let base = format!("/{owner_name}/{repo_name}/commits/{}", url_path(&spec)); let href_for = |n: u32| { if n <= 1 { base.clone() } else { format!("{base}?page={n}") } }; let newer_href = href_for(page_no.saturating_sub(1)); let older_href = href_for(page_no + 1); let has_newer = page_no > 1; let now = now_secs(); let tz = ctx.viewer_tz().to_owned(); let oids: Vec<&str> = commits.iter().map(|info| info.oid.as_str()).collect(); let statuses = state .store .statuses_for_many_rows(&ctx.repo.id, &oids) .await?; let shown_oids: Vec = commits .iter() .take(PAGE_SIZE) .map(|info| info.oid.clone()) .collect(); let sigs = signatures::badges(state, &ctx.repo, &shown_oids).await; let rows: Vec = commits .iter() .map(|info| { CommitRow::new(&owner_name, &repo_name, info, now, &tz, lang) .with_statuses(&statuses, &info.oid) .with_sig(&sigs, &info.oid, lang) }) .collect(); let empty = rows.is_empty(); let title = format!("{owner_name}/{repo_name}"); let clear_href = format!("/{owner_name}/{repo_name}/commits/{}", url_path(&ref_name)); let has_filter = filter.is_some(); let filter_text = filter.unwrap_or_default(); let page_label = g::page_label(lang, page_no); let header = ctx.head(state, "commits").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(g::t(lang, K::CommitsOn)) (ref_name.as_str())

if has_filter {

(g::t(lang, K::FilteredTo)) (filter_text.as_str()) " · " (g::t(lang, K::ClearFilter))

} if empty {

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

} else {
    for row in rows.iter() {
  • (row.summary.as_str())
    (row.author.as_str()) if lang == Lang::Tr { (g::t(lang, K::Committed)) } else { (g::t(lang, K::Committed)) }
    commit_status(summary: row.status.clone(), lang: StatusLang(lang)) if let Some(badge) = row.sig.clone() { (badge.label) } (row.short.as_str()) (g::t(lang, K::BrowseFilesShort))
  • }
} ) }) } /// Shared test fixture: a store with one owner, a bare repo built through the git CLI, and a /// router with the app context. Used by the commits, commit and archive page tests. #[cfg(test)] pub(crate) mod test_support { use std::path::{Path, PathBuf}; use std::process::Command; use forge_core::{Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; pub const OWNER: &str = "alice"; pub const REPO: &str = "demo"; pub struct Fixture { pub router: Router, pub store: Store, /// Root commit: adds `a.txt` and `dir/b.txt`. pub root: String, /// Adds `new.txt` and changes `a.txt`. pub modified: String, /// Working checkout and bare repo the fixture pushed to. pub work: PathBuf, pub bare: PathBuf, _dir: tempfile::TempDir, } pub fn git(dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .args([ "-c", "user.name=Tester", "-c", "user.email=tester@example.com", "-c", "commit.gpgsign=false", ]) .args(args) .current_dir(dir) .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_owned() } impl Fixture { pub async fn new(visibility: Visibility) -> Self { let tmp = tempfile::tempdir().expect("tempdir"); let dir = tmp.path().to_path_buf(); let state = crate::test_support::test_state(&dir).await; let store = state.store.clone(); let router = crate::test_support::app_router(state); let owner = store .create_user(OWNER, None, "not-a-real-hash", false) .await .unwrap(); let repo = store .create_repo(&owner.id, REPO, "", visibility, "main") .await .unwrap(); let bare = repo_dir(&dir, &repo.id); std::fs::create_dir_all(bare.parent().unwrap()).unwrap(); git( &dir, &[ "init", "-q", "--bare", "-b", "main", &bare.to_string_lossy(), ], ); let work = dir.join("work"); std::fs::create_dir_all(&work).unwrap(); git(&work, &["init", "-q", "-b", "main"]); std::fs::write(work.join("a.txt"), "one\ntwo\nthree\n").unwrap(); std::fs::create_dir_all(work.join("dir")).unwrap(); std::fs::write(work.join("dir/b.txt"), "b\n").unwrap(); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "root commit"]); let root = git(&work, &["rev-parse", "HEAD"]); std::fs::write(work.join("a.txt"), "one\nTWO\nthree\nfour\n").unwrap(); std::fs::write(work.join("new.txt"), "new file\n").unwrap(); git(&work, &["add", "-A"]); git( &work, &[ "commit", "-q", "-m", "Modify a.txt and add new.txt", "-m", "Body line one.\n\nSecond paragraph.", ], ); let modified = git(&work, &["rev-parse", "HEAD"]); // 53 empty commits on top: 55 in all, so history spans two pages. for n in 1..=53 { git( &work, &[ "commit", "-q", "--allow-empty", "-m", &format!("filler {n}"), ], ); } git( &work, &[ "push", "-q", &bare.to_string_lossy(), "HEAD:refs/heads/main", ], ); Self { router, store, root, modified, work, bare, _dir: tmp, } } } /// Same layout as `forge_git::repo::repo_path`, computed here so the test can create it. fn repo_dir(data_dir: &Path, repo_id: &str) -> PathBuf { let id = repo_id.to_ascii_lowercase(); data_dir .join("repos") .join(&id[0..2]) .join(&id[2..4]) .join(format!("{id}.git")) } /// GET through the router: `(status, headers, body)`. pub async fn get(router: &Router, uri: &str) -> (u16, http::HeaderMap, Vec) { let req = Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let headers = res.headers().clone(); let body = to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(); (status, headers, body) } } #[cfg(test)] mod tests { use forge_core::Visibility; use super::test_support::{Fixture, OWNER, REPO, get, git}; use super::{iso_time, url_path}; use crate::i18n::Lang; use crate::views::relative_time; #[test] fn formats_dates() { assert_eq!(iso_time(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_time(1_700_000_000), "2023-11-14T22:13:20Z"); } #[test] fn relative_units() { assert_eq!(relative_time(Lang::En, 990, 1000), "just now"); assert_eq!( relative_time(Lang::En, 10_000 - 3 * 86_400, 10_000), "3 days ago" ); assert_eq!( relative_time(Lang::En, 10_000 - 86_400, 10_000), "1 day ago" ); assert_eq!(url_path("a b/c#d"), "a%20b/c%23d"); } #[tokio::test] async fn commits_list_and_pagination() { let fx = Fixture::new(Visibility::Public).await; let base = format!("/{OWNER}/{REPO}/commits/main"); let (status, _, body) = get(&fx.router, &base).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert_eq!(html.matches("class=\"commit-row\"").count(), 50); assert!(html.contains("filler 53")); assert!(!html.contains("root commit")); assert!(html.contains(">Older<")); assert!(!html.contains(">Newer<")); let (status, _, body) = get(&fx.router, &format!("{base}?page=2")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert_eq!(html.matches("class=\"commit-row\"").count(), 5); assert!(html.contains("root commit")); assert!(html.contains(">Newer<")); assert!(!html.contains(">Older<")); let (status, _, _) = get(&fx.router, &format!("{base}?page=0")).await; assert_eq!(status, 400); let (status, _, _) = get(&fx.router, &format!("{base}?page=1001")).await; assert_eq!(status, 400); let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/nope")).await; assert_eq!(status, 404); } #[tokio::test] async fn path_filter_limits_history() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main/a.txt")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert_eq!(html.matches("class=\"commit-row\"").count(), 2); assert!(html.contains("Modify a.txt and add new.txt")); assert!(html.contains("root commit")); assert!(!html.contains("filler 53")); assert!(html.contains("Clear filter")); } #[tokio::test] async fn commit_list_shows_status_icon_and_contexts() { use forge_core::store::commit_status::StatusState; let fx = Fixture::new(Visibility::Public).await; let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .expect("repo"); let owner = fx .store .user_by_username(OWNER) .await .unwrap() .expect("owner"); fx.store .set_status( &repo.id, &fx.modified, "ci/build", StatusState::Success, Some("passed in 42s"), Some("https://ci.example/run/7"), &owner.id, ) .await .unwrap(); fx.store .set_status( &repo.id, &fx.modified, "lint", StatusState::Pending, None, None, &owner.id, ) .await .unwrap(); // The modified commit sits on page 2, below the 53 filler commits. let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main?page=2")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); // The combined state of the modified commit is pending; the root commit has none. assert_eq!(html.matches("class=\"commit-status\"").count(), 1); assert!(html.contains("status-icon status-pending")); assert!(html.contains("●")); assert!(html.contains("ci/build")); assert!(html.contains("passed in 42s")); assert!(html.contains("https://ci.example/run/7")); assert!(html.contains("rel=\"nofollow noopener noreferrer\"")); } #[tokio::test] async fn gitmoji_shortcodes_show_as_emoji() { let fx = Fixture::new(Visibility::Public).await; git( &fx.work, &[ "commit", "-q", "--allow-empty", "-m", ":sparkles: feat: init :spark:", "-m", "Body with :tada: and 12:30:45", ], ); let sha = git(&fx.work, &["rev-parse", "HEAD"]); git( &fx.work, &[ "push", "-q", &fx.bare.to_string_lossy(), "HEAD:refs/heads/main", ], ); let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(html.contains("✨ feat: init :spark:"), "{html}"); assert!(!html.contains(":sparkles:"), "{html}"); assert!(html.contains(&format!("/commit/{sha}")), "{html}"); let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{sha}")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(html.contains("✨ feat: init :spark:"), "{html}"); assert!(html.contains("Body with 🎉 and 12:30:45"), "{html}"); } #[tokio::test] async fn unsigned_commits_have_no_signature_badge() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main")).await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(html.contains("commit-row")); 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}/commits/main")).await; assert_eq!(status, 404); } }