1 //! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).2 //!3 //! Commit subjects and messages go through [`crate::layout::esc`]; diffs go4 //! through [`crate::layout::diff_html`] (escaped line-level color spans in an5 //! escaped `<pre>`, m6 diff-colors f0rge #22). Diffs come from6 //! [`crate::gitx::diff`] (binary content noted, output truncated past7 //! 2000 lines with a marker). Nothing here goes through markdown.8 9 use std::path::Path;10 11 use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title};12 use crate::gitx;13 use crate::layout::{diff_html, esc, page_site_with_provenance};14 15 /// Deterministic 1-letter author monogram (same rule as forge pages).16 fn monogram(handle: &str) -> String {17 let h = handle.trim();18 let initial: String = h19 .chars()20 .next()21 .map(|c| c.to_uppercase().to_string())22 .unwrap_or_default();23 let mut hash: u32 = 0;24 for b in h.bytes() {25 hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));26 }27 format!(28 "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",29 hash % 6,30 esc(&initial)31 )32 }33 34 /// Relative commit time with the exact value in `title` (f0rge #72/#74).35 fn time_ago(date: &str, now: i64) -> String {36 match parse_ts_secs(date) {37 Some(ts) => format!(38 "<time datetime=\"{}\" title=\"{}\">{}</time>",39 esc(date),40 esc(&ts_title(date)),41 esc(&relative_age(now, ts))42 ),43 None => format!("<time>{}</time>", esc(date)),44 }45 }46 47 /// Short display form of a full sha (first 7 hex chars, git-style).48 #[must_use]49 pub fn short_sha(sha: &str) -> &str {50 sha.get(..7).unwrap_or(sha)51 }52 53 /// Load up to `max_log` newest-first commits (the same vec rendered to54 /// `commits.html` and `api/commits.json` — one struct pass, no divergent logic).55 pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commit>, String> {56 gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))57 }58 59 /// Render `commits.html` plus one `commit/<sha>.html` per commit from an60 /// already-loaded log (see [`load`]).61 #[allow(clippy::too_many_arguments)]62 pub fn render_loaded(63 repo: &Path,64 out: &Path,65 log: &[gitx::Commit],66 site_name: &str,67 nav: &str,68 branch: &str,69 snap_sha: &str,70 description: Option<&str>,71 ) -> Result<usize, String> {72 let mut body = String::from("<div class=\"page-head\"><h1>Commits</h1></div>\n");73 if log.is_empty() {74 body.push_str("<p class=\"muted\">No commits yet.</p>\n");75 } else {76 let now = now_unix();77 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");78 for c in log {79 body.push_str(&format!(80 "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",81 esc(&c.subject),82 esc(&c.subject),83 esc(&c.sha),84 esc(&c.sha),85 esc(short_sha(&c.sha)),86 esc(&c.author),87 monogram(&c.author),88 esc(&c.author),89 esc(&c.date),90 time_ago(&c.date, now),91 ));92 }93 body.push_str("</tbody>\n</table></div>\n");94 }95 let html = page_site_with_provenance(96 &format!("commits — {site_name}"),97 nav,98 &body,99 site_name,100 description,101 branch,102 snap_sha,103 );104 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;105 std::fs::write(out.join("commits.html"), html)106 .map_err(|e| format!("hu13: write commits.html: {e}"))?;107 108 std::fs::create_dir_all(out.join("commit"))109 .map_err(|e| format!("hu13: create commit dir: {e}"))?;110 for c in log {111 let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;112 let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;113 let now = now_unix();114 let detail = format!(115 "<h1>{subject}</h1>\n<div class=\"meta\"><span class=\"chip\"><a href=\"/commit/{full}.html\"><code>{short}</code></a></span>{mono}<span class=\"who\">{author}</span><span class=\"meta-when\">{when}</span></div>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{message}</pre></div>\n<h2>Diff</h2>\n{diff}\n",116 subject = esc(&c.subject),117 full = esc(&c.sha),118 short = esc(short_sha(&c.sha)),119 mono = monogram(&c.author),120 author = esc(&c.author),121 when = time_ago(&c.date, now),122 message = esc(&message),123 diff = diff_html(&diff),124 );125 let html = page_site_with_provenance(126 &format!("commit {} — {site_name}", short_sha(&c.sha)),127 nav,128 &detail,129 site_name,130 description,131 branch,132 snap_sha,133 );134 std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html)135 .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?;136 }137 Ok(log.len())138 }139 140 /// Render `commits.html` plus details, loading the log itself.141 ///142 /// Wrapper around [`load`] + [`render_loaded`] for tests and simple callers.143 /// Lists at most `max_log` newest-first commits (see `--max-log`).144 #[allow(dead_code)]145 pub fn render(146 repo: &Path,147 out: &Path,148 branch: &str,149 max_log: usize,150 site_name: &str,151 nav: &str,152 description: Option<&str>,153 ) -> Result<usize, String> {154 let log = load(repo, branch, max_log)?;155 let snap_sha = gitx::rev_parse_short(repo, branch).unwrap_or_default();156 render_loaded(157 repo,158 out,159 &log,160 site_name,161 nav,162 branch,163 &snap_sha,164 description,165 )166 }167 168 #[cfg(test)]169 mod tests {170 use super::*;171 use std::path::PathBuf;172 use std::process::Command;173 use std::sync::atomic::{AtomicU64, Ordering};174 175 static COUNTER: AtomicU64 = AtomicU64::new(0);176 177 fn mktemp(prefix: &str) -> PathBuf {178 let id = COUNTER.fetch_add(1, Ordering::SeqCst);179 let dir =180 std::env::temp_dir().join(format!("hu13-commits-{prefix}-{}-{id}", std::process::id()));181 std::fs::create_dir_all(&dir).expect("mktemp");182 dir183 }184 185 fn git(repo: &Path, args: &[&str]) {186 let st = Command::new("git")187 .arg("-C")188 .arg(repo)189 .args(args)190 .status()191 .expect("spawn git");192 assert!(st.success(), "git {args:?} failed in {}", repo.display());193 }194 195 /// Fixture: 3 commits — plain text, evil message + binary change, huge diff.196 fn fixture_repo() -> PathBuf {197 let dir = mktemp("fixture");198 git(&dir, &["init", "-b", "main"]);199 git(&dir, &["config", "user.name", "hu13-test"]);200 git(&dir, &["config", "user.email", "[email protected]"]);201 git(&dir, &["config", "commit.gpgsign", "false"]);202 203 // Commit 1: plain text file.204 std::fs::write(dir.join("notes.txt"), "line one\n").unwrap();205 git(&dir, &["add", "-A"]);206 git(&dir, &["commit", "-m", "first commit"]);207 208 // Commit 2: evil message + text hunk + binary file change.209 std::fs::write(dir.join("notes.txt"), "line one\nline two added\n").unwrap();210 let bin: Vec<u8> = (0u8..=255).collect();211 std::fs::write(dir.join("bin.dat"), &bin).unwrap();212 git(&dir, &["add", "-A"]);213 git(214 &dir,215 &["commit", "-m", "second commit <script>alert(1)</script>"],216 );217 218 // Commit 3: huge diff (>2000 lines).219 let mut big = String::new();220 for i in 0..2500 {221 big.push_str(&format!("line {i}\n"));222 }223 std::fs::write(dir.join("big.txt"), &big).unwrap();224 git(&dir, &["add", "-A"]);225 git(&dir, &["commit", "-m", "third commit huge"]);226 227 dir228 }229 230 fn shas(repo: &Path) -> Vec<String> {231 gitx::log_n(repo, "main", 100)232 .expect("log")233 .iter()234 .map(|c| c.sha.clone())235 .collect()236 }237 238 #[test]239 fn list_order_newest_first_with_links() {240 let repo = fixture_repo();241 let out = mktemp("out");242 let n = render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");243 assert_eq!(n, 3);244 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");245 let p3 = list.find("third commit huge").expect("c3");246 let p2 = list.find("second commit").expect("c2");247 let p1 = list.find("first commit").expect("c1");248 assert!(p3 < p2 && p2 < p1, "newest first");249 for sha in shas(&repo) {250 assert!(251 list.contains(&format!("/commit/{sha}.html")),252 "detail link for {sha}"253 );254 assert!(255 list.contains(sha.get(..7).unwrap_or(&sha)),256 "short sha for {sha}"257 );258 }259 assert!(list.contains("hu13-test"), "author shown");260 }261 262 #[test]263 fn detail_escapes_message_and_hunk() {264 let repo = fixture_repo();265 let out = mktemp("out");266 render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");267 let sha = shas(&repo)[1].clone(); // evil-message + binary commit268 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))269 .expect("detail");270 assert!(271 !detail.contains("<script>alert(1)</script>"),272 "message escaped"273 );274 assert!(detail.contains("<script>"), "escaped form present");275 assert!(276 detail.contains("line two added"),277 "diff hunk present: {detail}"278 );279 assert!(280 detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),281 "binary change noted"282 );283 assert!(detail.contains("<pre class=\"diff\">"), "diff in <pre>");284 }285 286 #[test]287 fn huge_diff_truncates_with_marker() {288 let repo = fixture_repo();289 let out = mktemp("out");290 render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");291 let sha = shas(&repo)[0].clone(); // huge commit292 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))293 .expect("detail");294 assert!(295 detail.contains(gitx::DIFF_TRUNCATED_MARKER),296 "truncation marker shown"297 );298 }299 300 #[test]301 fn diff_lines_carry_color_spans() {302 // m6 diff-colors (f0rge #22): hunk cyan, +++/--- bold, + green, - red.303 let repo = fixture_repo();304 let out = mktemp("out");305 render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");306 let sha = shas(&repo)[1].clone(); // text hunk + binary commit307 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))308 .expect("detail");309 assert!(310 detail.contains("<span class=\"diff-hunk\">@@"),311 "hunk header cyan: {detail}"312 );313 assert!(314 detail.contains("<span class=\"diff-file\">---"),315 "--- file line bold: {detail}"316 );317 assert!(318 detail.contains("<span class=\"diff-file\">+++"),319 "+++ file line bold: {detail}"320 );321 assert!(322 detail.contains("<span class=\"diff-add\">+line two added</span>"),323 "added line green: {detail}"324 );325 // Binary note + truncation marker behavior intact (no span, still shown).326 assert!(327 detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),328 "binary note intact"329 );330 let huge_sha = shas(&repo)[0].clone();331 let huge = std::fs::read_to_string(out.join("commit").join(format!("{huge_sha}.html")))332 .expect("huge detail");333 assert!(334 huge.contains(gitx::DIFF_TRUNCATED_MARKER),335 "truncation marker intact with spans"336 );337 }338 339 #[test]340 fn diff_escaping_intact_inside_spans() {341 // Evil `<script>` added line: escaped, still inside the green span.342 let dir = mktemp("evil-diff");343 git(&dir, &["init", "-b", "main"]);344 git(&dir, &["config", "user.name", "hu13-test"]);345 git(&dir, &["config", "user.email", "[email protected]"]);346 git(&dir, &["config", "commit.gpgsign", "false"]);347 std::fs::write(dir.join("x.txt"), "ok\n").unwrap();348 git(&dir, &["add", "-A"]);349 git(&dir, &["commit", "-m", "base"]);350 std::fs::write(dir.join("x.txt"), "ok\nevil <script>alert(1)</script>\n").unwrap();351 git(&dir, &["add", "-A"]);352 git(&dir, &["commit", "-m", "evil diff"]);353 let out = mktemp("out");354 render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");355 let sha = shas(&dir)[0].clone();356 let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))357 .expect("detail");358 assert!(!detail.contains("<script>alert(1)</script>"), "no raw HTML");359 assert!(360 detail.contains("<script>"),361 "escaped form present: {detail}"362 );363 assert!(364 detail.contains("<span class=\"diff-add\">+evil <script>"),365 "escaped inside green span: {detail}"366 );367 }368 369 #[test]370 fn max_log_limits_list_and_details() {371 let repo = fixture_repo();372 let out = mktemp("out");373 let n = render(&repo, &out, "main", 2, "test", "<nav>", None).expect("render");374 assert_eq!(n, 2);375 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");376 assert!(list.contains("third commit huge"));377 assert!(list.contains("second commit"));378 assert!(!list.contains("first commit"), "oldest commit cut off");379 let all = shas(&repo);380 assert!(out.join("commit").join(format!("{}.html", all[0])).exists());381 assert!(!out.join("commit").join(format!("{}.html", all[2])).exists());382 }383 384 /// m6/table-scroll (f0rge #30): long subject + full sha render inside a385 /// .table-scroll wrapper with td.title / td.sha cells.386 #[test]387 fn long_subject_scrolls_with_title_cell() {388 let dir = mktemp("long-subject");389 git(&dir, &["init", "-b", "main"]);390 git(&dir, &["config", "user.name", "hu13-test"]);391 git(&dir, &["config", "user.email", "[email protected]"]);392 git(&dir, &["config", "commit.gpgsign", "false"]);393 let long_subject = "s".repeat(200);394 std::fs::write(dir.join("x.txt"), "x\n").unwrap();395 git(&dir, &["add", "-A"]);396 git(&dir, &["commit", "-m", &long_subject]);397 let out = mktemp("out");398 render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");399 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");400 assert!(401 list.contains("<div class=\"table-scroll\"><table>"),402 "wrapper"403 );404 assert!(405 list.contains("<td class=\"title trunc trunc-subject\""),406 "title cell trunc"407 );408 assert!(409 list.contains("<td class=\"sha trunc trunc-sha\""),410 "sha cell trunc"411 );412 assert!(list.contains("title=\""), "title attrs kept");413 assert!(list.contains(&long_subject), "long subject kept");414 let sha = shas(&dir)[0].clone();415 assert!(416 list.contains(&format!("/commit/{sha}.html")),417 "full sha link"418 );419 }420 421 #[test]422 fn empty_log_renders_none_yet() {423 let dir = mktemp("empty");424 git(&dir, &["init", "-b", "main"]);425 git(&dir, &["config", "user.name", "hu13-test"]);426 git(&dir, &["config", "user.email", "[email protected]"]);427 git(&dir, &["config", "commit.gpgsign", "false"]);428 std::fs::write(dir.join("empty.txt"), "x\n").unwrap();429 git(&dir, &["add", "-A"]);430 git(&dir, &["commit", "-m", "only commit"]);431 let out = mktemp("out");432 let n = render(&dir, &out, "main", 0, "test", "<nav>", None).expect("render");433 assert_eq!(n, 0);434 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");435 assert!(list.contains("No commits yet."));436 }437 438 /// m6/no-scroll (f0rge #39): commit rows carry data-labels for cards.439 #[test]440 fn commit_cells_have_data_labels() {441 let css = include_str!("../static/style.css");442 assert!(css.contains("content:attr(data-label)"), "label rule");443 assert!(css.contains("@media (max-width:767px)"), "narrow query");444 let dir = mktemp("labels");445 git(&dir, &["init", "-b", "main"]);446 git(&dir, &["config", "user.name", "hu13-test"]);447 git(&dir, &["config", "user.email", "[email protected]"]);448 git(&dir, &["config", "commit.gpgsign", "false"]);449 std::fs::write(dir.join("x.txt"), "x\n").unwrap();450 git(&dir, &["add", "-A"]);451 git(&dir, &["commit", "-m", "labeled subject"]);452 let out = mktemp("out");453 render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");454 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");455 let tds = list.matches("<td").count();456 assert!(tds > 0, "has cells");457 assert_eq!(list.matches("data-label=").count(), tds, "every td labeled");458 for label in ["sha", "subject", "author", "date"] {459 assert!(460 list.contains(&format!("data-label=\"{label}\"")),461 "label {label}"462 );463 }464 // m6/cards2 (f0rge #47): card DOM order is subject-first.465 let subj = list.find("data-label=\"subject\"").expect("subject cell");466 let sha = list.find("data-label=\"sha\"").expect("sha cell");467 assert!(subj < sha, "card DOM: subject precedes sha");468 }469 }