src/commits.rs 18.0 KiB 469 lines Raw
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("&lt;script&gt;"), "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("&lt;script&gt;"),361             "escaped form present: {detail}"362         );363         assert!(364             detail.contains("<span class=\"diff-add\">+evil &lt;script&gt;"),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 }