forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
23.4 KiB
1//! Page: commit history for a ref and optional path, at `/{owner}/{repo}/commits/{*rest}`.
2//! Also home of the helpers shared by the commit and archive pages.
3
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use std::collections::HashMap;
7
8use forge_core::store::commit_status::CommitStatus;
9use forge_core::tz::format_in_tz;
10use forge_git::browse::{self, CommitInfo};
11use forge_git::history;
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::error::{bad_request, not_found};
15use topcoat::router::{page, path_param, query_params};
16use topcoat::view::{View, view};
17
18use crate::i18n::code::{self as g, K};
19use crate::i18n::{self, Lang};
20use crate::pages::repo::repo_head;
21use crate::pages::repo_ctx::load_repo;
22use crate::render::emoji::emojify;
23use crate::signatures::{self, SigBadge};
24use crate::state::AppState;
25use crate::views::commit_status::{StatusLang, StatusSummary, commit_status};
26use crate::views::shell;
27
28path_param!(owner);
29path_param!(repo);
30path_param!(*rest);
31
32/// Commits per history page.
33pub(crate) const PAGE_SIZE: usize = 50;
34/// Highest accepted `?page=` value.
35const MAX_PAGE: u32 = 1000;
36
37#[query_params(error = bad_request)]
38struct CommitsQuery {
39 page: Option<u32>,
40}
41
42/// Runs a blocking `browse::*` call off the async runtime.
43pub(crate) async fn blocking<T, F>(f: F) -> Result<T>
44where
45 F: FnOnce() -> forge_git::Result<T> + Send + 'static,
46 T: Send + 'static,
47{
48 Ok(tokio::task::spawn_blocking(f).await??)
49}
50
51/// Current Unix time in seconds.
52pub(crate) fn now_secs() -> i64 {
53 SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .ok()
56 .and_then(|d| i64::try_from(d.as_secs()).ok())
57 .unwrap_or(0)
58}
59
60/// Days since the Unix epoch to a proleptic Gregorian `(year, month, day)`.
61fn civil_from_days(days: i64) -> (i64, u32, u32) {
62 let z = days + 719_468;
63 let era = z.div_euclid(146_097);
64 let doe = z - era * 146_097;
65 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
66 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
67 let mp = (5 * doy + 2) / 153;
68 let day = doy - (153 * mp + 2) / 5 + 1;
69 let month = if mp < 10 { mp + 3 } else { mp - 9 };
70 let year = yoe + era * 400 + i64::from(month <= 2);
71 (year, month as u32, day as u32)
72}
73
74/// RFC 3339 UTC stamp for the `datetime` attribute.
75pub(crate) fn iso_time(at: i64) -> String {
76 let (y, m, d) = civil_from_days(at.div_euclid(86_400));
77 let secs = at.rem_euclid(86_400);
78 format!(
79 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
80 secs / 3600,
81 secs % 3600 / 60,
82 secs % 60
83 )
84}
85
86/// Percent-encodes each byte outside the unreserved set, keeping `/` as the separator.
87pub(crate) fn url_path(path: &str) -> String {
88 let mut out = String::with_capacity(path.len());
89 for b in path.bytes() {
90 match b {
91 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
92 out.push(char::from(b));
93 }
94 _ => out.push_str(&format!("%{b:02X}")),
95 }
96 }
97 out
98}
99
100/// Seven-character abbreviation, as shown in lists.
101pub(crate) fn short_oid(oid: &str) -> &str {
102 oid.get(..7).unwrap_or(oid)
103}
104
105/// One commit as shown in the history list.
106pub(crate) struct CommitRow {
107 pub summary: String,
108 pub author: String,
109 pub commit_href: String,
110 pub tree_href: String,
111 pub short: String,
112 pub relative: String,
113 pub absolute: String,
114 pub iso: String,
115 /// Commit status icon and dropdown. Empty unless `with_statuses` attached some.
116 pub status: StatusSummary,
117 /// Signature badge. `None` for unsigned commits and commits not checked on this page.
118 pub sig: Option<SigBadgeView>,
119}
120
121/// A signature badge prepared for display: the CSS classes, the visible label and the title.
122#[derive(Clone)]
123pub(crate) struct SigBadgeView {
124 pub class: &'static str,
125 pub label: &'static str,
126 pub title: String,
127}
128
129impl SigBadgeView {
130 pub(crate) fn from_badge(badge: &SigBadge, lang: Lang) -> Self {
131 match badge {
132 SigBadge::Verified { signer, key } => Self {
133 class: "sig-badge sig-verified",
134 label: g::t(lang, K::SigVerified),
135 title: g::signed_by(lang, signer, key),
136 },
137 SigBadge::Unverified { reason } => Self {
138 class: "sig-badge sig-unverified",
139 label: g::t(lang, K::SigUnverified),
140 title: reason.clone(),
141 },
142 }
143 }
144}
145
146impl CommitRow {
147 pub(crate) fn new(
148 owner: &str,
149 repo: &str,
150 info: &CommitInfo,
151 now: i64,
152 tz: &str,
153 lang: Lang,
154 ) -> Self {
155 Self {
156 summary: emojify(&info.summary).into_owned(),
157 author: info.author_name.clone(),
158 commit_href: format!("/{owner}/{repo}/commit/{}", info.oid),
159 tree_href: format!("/{owner}/{repo}/tree/{}", info.oid),
160 short: short_oid(&info.oid).to_owned(),
161 relative: crate::views::relative_time(lang, info.author_time, now),
162 absolute: format_in_tz(info.author_time, tz),
163 iso: iso_time(info.author_time),
164 status: StatusSummary::default(),
165 sig: None,
166 }
167 }
168
169 /// Attaches the signature badge of `oid` from the batched `signatures::badges` lookup.
170 pub(crate) fn with_sig(
171 mut self,
172 badges: &HashMap<String, SigBadge>,
173 oid: &str,
174 lang: Lang,
175 ) -> Self {
176 self.sig = badges
177 .get(oid)
178 .map(|badge| SigBadgeView::from_badge(badge, lang));
179 self
180 }
181
182 /// Attaches the statuses of `oid` from a batched lookup (see `statuses_for_many_rows`).
183 pub(crate) fn with_statuses(
184 mut self,
185 statuses: &HashMap<String, Vec<CommitStatus>>,
186 oid: &str,
187 ) -> Self {
188 self.status = statuses
189 .get(oid)
190 .map(|rows| StatusSummary::from_rows(rows))
191 .unwrap_or_default();
192 self
193 }
194}
195
196#[page("/{owner}/{repo}/commits/{*rest}")]
197async fn commits_page(cx: &Cx) -> Result<impl View> {
198 let owner = path_param::<Owner>(cx);
199 let name = path_param::<Repo>(cx);
200 let rest = path_param::<Rest>(cx).collect::<Vec<_>>().join("/");
201 let query = query_params::<CommitsQuery>(cx)?;
202 let page_no = query.page.unwrap_or(1);
203 if !(1..=MAX_PAGE).contains(&page_no) {
204 return Err(bad_request("page must be between 1 and 1000").into());
205 }
206
207 let ctx = load_repo(cx, owner, name).await?;
208 let state = app_context::<AppState>(cx);
209 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
210
211 let repo_dir = ctx.path.clone();
212 let Some((ref_name, commit, path)) =
213 blocking(move || browse::split_ref_and_path(&repo_dir, &rest)).await?
214 else {
215 return Err(not_found().into());
216 };
217 let filter = (!path.is_empty()).then(|| path.clone());
218
219 let skip = (page_no as usize - 1) * PAGE_SIZE;
220 let mut commits = history::log(
221 &state.git,
222 &ctx.path,
223 &commit,
224 filter.as_deref(),
225 skip,
226 PAGE_SIZE + 1,
227 )
228 .await?;
229 let has_older = commits.len() > PAGE_SIZE;
230 commits.truncate(PAGE_SIZE);
231
232 let owner_name = ctx.repo.owner_name.clone();
233 let repo_name = ctx.repo.name.clone();
234 let spec = match &filter {
235 Some(p) => format!("{ref_name}/{p}"),
236 None => ref_name.clone(),
237 };
238 let base = format!("/{owner_name}/{repo_name}/commits/{}", url_path(&spec));
239 let href_for = |n: u32| {
240 if n <= 1 {
241 base.clone()
242 } else {
243 format!("{base}?page={n}")
244 }
245 };
246 let newer_href = href_for(page_no.saturating_sub(1));
247 let older_href = href_for(page_no + 1);
248 let has_newer = page_no > 1;
249
250 let now = now_secs();
251 let tz = ctx.viewer_tz().to_owned();
252 let oids: Vec<&str> = commits.iter().map(|info| info.oid.as_str()).collect();
253 let statuses = state
254 .store
255 .statuses_for_many_rows(&ctx.repo.id, &oids)
256 .await?;
257 let shown_oids: Vec<String> = commits
258 .iter()
259 .take(PAGE_SIZE)
260 .map(|info| info.oid.clone())
261 .collect();
262 let sigs = signatures::badges(state, &ctx.repo, &shown_oids).await;
263 let rows: Vec<CommitRow> = commits
264 .iter()
265 .map(|info| {
266 CommitRow::new(&owner_name, &repo_name, info, now, &tz, lang)
267 .with_statuses(&statuses, &info.oid)
268 .with_sig(&sigs, &info.oid, lang)
269 })
270 .collect();
271 let empty = rows.is_empty();
272 let title = format!("{owner_name}/{repo_name}");
273 let clear_href = format!("/{owner_name}/{repo_name}/commits/{}", url_path(&ref_name));
274 let has_filter = filter.is_some();
275 let filter_text = filter.unwrap_or_default();
276 let page_label = g::page_label(lang, page_no);
277
278 let header = ctx.head(state, "commits").await?;
279 Ok(view! {
280 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
281 repo_head(head: header)
282 <p class="muted">(g::t(lang, K::CommitsOn)) <code>(ref_name.as_str())</code></p>
283 if has_filter {
284 <p class="muted">(g::t(lang, K::FilteredTo)) <code>(filter_text.as_str())</code> " · " <a href=(clear_href.as_str())>(g::t(lang, K::ClearFilter))</a></p>
285 }
286 if empty {
287 <p class="muted">(g::t(lang, K::NoCommits))</p>
288 } else {
289 <ul class="commit-list">
290 for row in rows.iter() {
291 <li class="commit-row">
292 <div class="commit-main">
293 <a class="commit-summary" href=(row.commit_href.as_str())>(row.summary.as_str())</a>
294 <div class="commit-meta muted small">
295 <span class="commit-author">(row.author.as_str())</span>
296 if lang == Lang::Tr {
297 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
298 <span>(g::t(lang, K::Committed))</span>
299 } else {
300 <span>(g::t(lang, K::Committed))</span>
301 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
302 }
303 </div>
304 </div>
305 <div class="commit-actions">
306 commit_status(summary: row.status.clone(), lang: StatusLang(lang))
307 if let Some(badge) = row.sig.clone() {
308 <span class=(badge.class) title=(badge.title.as_str())>(badge.label)</span>
309 }
310 <a class="mono commit-oid" href=(row.commit_href.as_str())>(row.short.as_str())</a>
311 <a class="small muted" href=(row.tree_href.as_str())>(g::t(lang, K::BrowseFilesShort))</a>
312 </div>
313 </li>
314 }
315 </ul>
316 }
317 <nav class="pager">
318 if has_newer {
319 <a href=(newer_href.as_str())>(g::t(lang, K::NewerLink))</a>
320 }
321 <span class="muted small">(page_label.as_str())</span>
322 if has_older {
323 <a href=(older_href.as_str())>(g::t(lang, K::OlderLink))</a>
324 }
325 </nav>
326 )
327 })
328}
329
330/// Shared test fixture: a store with one owner, a bare repo built through the git CLI, and a
331/// router with the app context. Used by the commits, commit and archive page tests.
332#[cfg(test)]
333pub(crate) mod test_support {
334 use std::path::{Path, PathBuf};
335 use std::process::Command;
336
337 use forge_core::{Store, Visibility};
338
339 use topcoat::router::request::Request;
340 use topcoat::router::{Body, Router, to_bytes};
341
342 pub const OWNER: &str = "alice";
343 pub const REPO: &str = "demo";
344
345 pub struct Fixture {
346 pub router: Router,
347 pub store: Store,
348 /// Root commit: adds `a.txt` and `dir/b.txt`.
349 pub root: String,
350 /// Adds `new.txt` and changes `a.txt`.
351 pub modified: String,
352 /// Working checkout and bare repo the fixture pushed to.
353 pub work: PathBuf,
354 pub bare: PathBuf,
355 _dir: tempfile::TempDir,
356 }
357
358 pub fn git(dir: &Path, args: &[&str]) -> String {
359 let out = Command::new("git")
360 .args([
361 "-c",
362 "user.name=Tester",
363 "-c",
364 "user.email=tester@example.com",
365 "-c",
366 "commit.gpgsign=false",
367 ])
368 .args(args)
369 .current_dir(dir)
370 .output()
371 .expect("git runs");
372 assert!(
373 out.status.success(),
374 "git {args:?} failed: {}",
375 String::from_utf8_lossy(&out.stderr)
376 );
377 String::from_utf8_lossy(&out.stdout).trim().to_owned()
378 }
379
380 impl Fixture {
381 pub async fn new(visibility: Visibility) -> Self {
382 let tmp = tempfile::tempdir().expect("tempdir");
383 let dir = tmp.path().to_path_buf();
384 let state = crate::test_support::test_state(&dir).await;
385 let store = state.store.clone();
386 let router = crate::test_support::app_router(state);
387
388 let owner = store
389 .create_user(OWNER, None, "not-a-real-hash", false)
390 .await
391 .unwrap();
392 let repo = store
393 .create_repo(&owner.id, REPO, "", visibility, "main")
394 .await
395 .unwrap();
396 let bare = repo_dir(&dir, &repo.id);
397 std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
398 git(
399 &dir,
400 &[
401 "init",
402 "-q",
403 "--bare",
404 "-b",
405 "main",
406 &bare.to_string_lossy(),
407 ],
408 );
409
410 let work = dir.join("work");
411 std::fs::create_dir_all(&work).unwrap();
412 git(&work, &["init", "-q", "-b", "main"]);
413 std::fs::write(work.join("a.txt"), "one\ntwo\nthree\n").unwrap();
414 std::fs::create_dir_all(work.join("dir")).unwrap();
415 std::fs::write(work.join("dir/b.txt"), "b\n").unwrap();
416 git(&work, &["add", "-A"]);
417 git(&work, &["commit", "-q", "-m", "root commit"]);
418 let root = git(&work, &["rev-parse", "HEAD"]);
419
420 std::fs::write(work.join("a.txt"), "one\nTWO\nthree\nfour\n").unwrap();
421 std::fs::write(work.join("new.txt"), "new file\n").unwrap();
422 git(&work, &["add", "-A"]);
423 git(
424 &work,
425 &[
426 "commit",
427 "-q",
428 "-m",
429 "Modify a.txt and add new.txt",
430 "-m",
431 "Body line one.\n\nSecond paragraph.",
432 ],
433 );
434 let modified = git(&work, &["rev-parse", "HEAD"]);
435
436 // 53 empty commits on top: 55 in all, so history spans two pages.
437 for n in 1..=53 {
438 git(
439 &work,
440 &[
441 "commit",
442 "-q",
443 "--allow-empty",
444 "-m",
445 &format!("filler {n}"),
446 ],
447 );
448 }
449 git(
450 &work,
451 &[
452 "push",
453 "-q",
454 &bare.to_string_lossy(),
455 "HEAD:refs/heads/main",
456 ],
457 );
458
459 Self {
460 router,
461 store,
462 root,
463 modified,
464 work,
465 bare,
466 _dir: tmp,
467 }
468 }
469 }
470
471 /// Same layout as `forge_git::repo::repo_path`, computed here so the test can create it.
472 fn repo_dir(data_dir: &Path, repo_id: &str) -> PathBuf {
473 let id = repo_id.to_ascii_lowercase();
474 data_dir
475 .join("repos")
476 .join(&id[0..2])
477 .join(&id[2..4])
478 .join(format!("{id}.git"))
479 }
480
481 /// GET through the router: `(status, headers, body)`.
482 pub async fn get(router: &Router, uri: &str) -> (u16, http::HeaderMap, Vec<u8>) {
483 let req = Request::builder()
484 .method("GET")
485 .uri(uri)
486 .body(Body::empty())
487 .unwrap();
488 let res = router.handle(req).await;
489 let status = res.status().as_u16();
490 let headers = res.headers().clone();
491 let body = to_bytes(res.into_body(), usize::MAX)
492 .await
493 .unwrap()
494 .to_vec();
495 (status, headers, body)
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use forge_core::Visibility;
502
503 use super::test_support::{Fixture, OWNER, REPO, get, git};
504 use super::{iso_time, url_path};
505 use crate::i18n::Lang;
506 use crate::views::relative_time;
507
508 #[test]
509 fn formats_dates() {
510 assert_eq!(iso_time(0), "1970-01-01T00:00:00Z");
511 assert_eq!(iso_time(1_700_000_000), "2023-11-14T22:13:20Z");
512 }
513
514 #[test]
515 fn relative_units() {
516 assert_eq!(relative_time(Lang::En, 990, 1000), "just now");
517 assert_eq!(
518 relative_time(Lang::En, 10_000 - 3 * 86_400, 10_000),
519 "3 days ago"
520 );
521 assert_eq!(
522 relative_time(Lang::En, 10_000 - 86_400, 10_000),
523 "1 day ago"
524 );
525 assert_eq!(url_path("a b/c#d"), "a%20b/c%23d");
526 }
527
528 #[tokio::test]
529 async fn commits_list_and_pagination() {
530 let fx = Fixture::new(Visibility::Public).await;
531 let base = format!("/{OWNER}/{REPO}/commits/main");
532
533 let (status, _, body) = get(&fx.router, &base).await;
534 assert_eq!(status, 200);
535 let html = String::from_utf8(body).unwrap();
536 assert_eq!(html.matches("class=\"commit-row\"").count(), 50);
537 assert!(html.contains("filler 53"));
538 assert!(!html.contains("root commit"));
539 assert!(html.contains(">Older<"));
540 assert!(!html.contains(">Newer<"));
541
542 let (status, _, body) = get(&fx.router, &format!("{base}?page=2")).await;
543 assert_eq!(status, 200);
544 let html = String::from_utf8(body).unwrap();
545 assert_eq!(html.matches("class=\"commit-row\"").count(), 5);
546 assert!(html.contains("root commit"));
547 assert!(html.contains(">Newer<"));
548 assert!(!html.contains(">Older<"));
549
550 let (status, _, _) = get(&fx.router, &format!("{base}?page=0")).await;
551 assert_eq!(status, 400);
552 let (status, _, _) = get(&fx.router, &format!("{base}?page=1001")).await;
553 assert_eq!(status, 400);
554 let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/nope")).await;
555 assert_eq!(status, 404);
556 }
557
558 #[tokio::test]
559 async fn path_filter_limits_history() {
560 let fx = Fixture::new(Visibility::Public).await;
561 let (status, _, body) =
562 get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main/a.txt")).await;
563 assert_eq!(status, 200);
564 let html = String::from_utf8(body).unwrap();
565 assert_eq!(html.matches("class=\"commit-row\"").count(), 2);
566 assert!(html.contains("Modify a.txt and add new.txt"));
567 assert!(html.contains("root commit"));
568 assert!(!html.contains("filler 53"));
569 assert!(html.contains("Clear filter"));
570 }
571
572 #[tokio::test]
573 async fn commit_list_shows_status_icon_and_contexts() {
574 use forge_core::store::commit_status::StatusState;
575
576 let fx = Fixture::new(Visibility::Public).await;
577 let repo = fx
578 .store
579 .repo_by_owner_and_name(OWNER, REPO)
580 .await
581 .unwrap()
582 .expect("repo");
583 let owner = fx
584 .store
585 .user_by_username(OWNER)
586 .await
587 .unwrap()
588 .expect("owner");
589 fx.store
590 .set_status(
591 &repo.id,
592 &fx.modified,
593 "ci/build",
594 StatusState::Success,
595 Some("passed in 42s"),
596 Some("https://ci.example/run/7"),
597 &owner.id,
598 )
599 .await
600 .unwrap();
601 fx.store
602 .set_status(
603 &repo.id,
604 &fx.modified,
605 "lint",
606 StatusState::Pending,
607 None,
608 None,
609 &owner.id,
610 )
611 .await
612 .unwrap();
613
614 // The modified commit sits on page 2, below the 53 filler commits.
615 let (status, _, body) =
616 get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main?page=2")).await;
617 assert_eq!(status, 200);
618 let html = String::from_utf8(body).unwrap();
619 // The combined state of the modified commit is pending; the root commit has none.
620 assert_eq!(html.matches("class=\"commit-status\"").count(), 1);
621 assert!(html.contains("status-icon status-pending"));
622 assert!(html.contains("●"));
623 assert!(html.contains("<code>ci/build</code>"));
624 assert!(html.contains("passed in 42s"));
625 assert!(html.contains("https://ci.example/run/7"));
626 assert!(html.contains("rel=\"nofollow noopener noreferrer\""));
627 }
628
629 #[tokio::test]
630 async fn gitmoji_shortcodes_show_as_emoji() {
631 let fx = Fixture::new(Visibility::Public).await;
632 git(
633 &fx.work,
634 &[
635 "commit",
636 "-q",
637 "--allow-empty",
638 "-m",
639 ":sparkles: feat: init :spark:",
640 "-m",
641 "Body with :tada: and 12:30:45",
642 ],
643 );
644 let sha = git(&fx.work, &["rev-parse", "HEAD"]);
645 git(
646 &fx.work,
647 &[
648 "push",
649 "-q",
650 &fx.bare.to_string_lossy(),
651 "HEAD:refs/heads/main",
652 ],
653 );
654
655 let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main")).await;
656 assert_eq!(status, 200);
657 let html = String::from_utf8(body).unwrap();
658 assert!(html.contains("✨ feat: init :spark:"), "{html}");
659 assert!(!html.contains(":sparkles:"), "{html}");
660 assert!(html.contains(&format!("/commit/{sha}")), "{html}");
661
662 let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{sha}")).await;
663 assert_eq!(status, 200);
664 let html = String::from_utf8(body).unwrap();
665 assert!(html.contains("✨ feat: init :spark:"), "{html}");
666 assert!(html.contains("Body with 🎉 and 12:30:45"), "{html}");
667 }
668
669 #[tokio::test]
670 async fn unsigned_commits_have_no_signature_badge() {
671 let fx = Fixture::new(Visibility::Public).await;
672 let (status, _, body) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main")).await;
673 assert_eq!(status, 200);
674 let html = String::from_utf8(body).unwrap();
675 assert!(html.contains("commit-row"));
676 assert!(!html.contains("sig-badge"));
677 }
678
679 #[tokio::test]
680 async fn private_repo_is_404_for_anonymous() {
681 let fx = Fixture::new(Visibility::Private).await;
682 let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commits/main")).await;
683 assert_eq!(status, 404);
684 }
685}