From be7802d600d44d7725868e1ffc4415f5a364c6c4 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:15:09 +0100 Subject: [PATCH] m2/commits-wiki: commit pages+diffs, wiki index+pages (37 tests) --- src/build.rs | 54 +++++++-- src/commits.rs | 243 +++++++++++++++++++++++++++++++++++++ src/gitx.rs | 31 +++++ src/main.rs | 2 + src/wiki.rs | 322 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 src/commits.rs create mode 100644 src/wiki.rs diff --git a/src/build.rs b/src/build.rs index 5280528..4530f1a 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,17 +1,19 @@ -//! `hu13 build` — code-tab static site (m1, f0rge issue #4). +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5). //! //! Reads one branch via [`crate::gitx`], emits `dist/`: //! `index.html`, `files/.html`, `raw/` (byte-identical), -//! `repo.bundle` + `repo.tar.gz`, and `style.css`. +//! `commits.html`, `commit/.html`, `wiki/index.html`, +//! `wiki/.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`. //! //! Bodies from the repo are semi-trusted but treated as untrusted: -//! markdown goes through [`markdown_to_html`], which escapes raw HTML. +//! markdown goes through [`markdown_to_html`], which escapes raw HTML; +//! commit messages and diffs are escaped `
` (never markdown).
 
 use std::collections::BTreeMap;
 use std::path::Path;
 
-use crate::gitx;
 use crate::layout::esc;
+use crate::{commits, gitx, wiki};
 
 /// Files at or above this size get a placeholder page + download link.
 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;
@@ -25,7 +27,7 @@ pub struct BuildOptions {
     pub clone_url_flag: Option,
     /// `--title` site title override (else repo dir name).
     pub title_override: Option,
-    /// `--max-log` (accepted for CLI parity; commits land in m2).
+    /// `--max-log`: max commits listed in `commits.html` (default 100).
     pub max_log: usize,
 }
 
@@ -144,7 +146,7 @@ fn render_text_pre(display: &str) -> String {
 }
 
 fn nav_code() -> String {
-    "code "
+    "code · commits · wiki "
         .to_string()
 }
 
@@ -156,7 +158,6 @@ fn write_style(out: &Path) -> Result<(), String> {
 
 /// Build the full static site. Returns a summary on success.
 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result {
-    let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity
     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())
         .map_err(|e| format!("hu13: {e}"))?;
     let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;
@@ -333,6 +334,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultalert(1)\n\n**bold**\n");
diff --git a/src/commits.rs b/src/commits.rs
new file mode 100644
index 0000000..834971c
--- /dev/null
+++ b/src/commits.rs
@@ -0,0 +1,243 @@
+//! `commits.html` + `commit/.html` (m2, f0rge issue #5).
+//!
+//! Commit subjects, messages, and diffs are untrusted: everything goes
+//! through [`crate::layout::esc`] into an escaped `
`. Diffs come from
+//! [`crate::gitx::diff`] (binary content noted, output truncated past
+//! 2000 lines with a marker). Nothing here goes through markdown.
+
+use std::path::Path;
+
+use crate::gitx;
+use crate::layout::{esc, page};
+
+/// Short display form of a full sha (first 7 hex chars, git-style).
+#[must_use]
+pub fn short_sha(sha: &str) -> &str {
+    sha.get(..7).unwrap_or(sha)
+}
+
+/// Render `commits.html` plus one `commit/.html` per listed commit.
+///
+/// Lists at most `max_log` newest-first commits (see `--max-log`).
+/// Returns the number of commits listed.
+pub fn render(
+    repo: &Path,
+    out: &Path,
+    branch: &str,
+    max_log: usize,
+    site_name: &str,
+    nav: &str,
+) -> Result {
+    let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;
+
+    let mut body = String::from("

Commits

\n"); + if log.is_empty() { + body.push_str("

No commits yet.

\n"); + } else { + body.push_str("\n\n\n"); + for c in &log { + body.push_str(&format!( + "\n", + esc(&c.sha), + esc(short_sha(&c.sha)), + esc(&c.subject), + esc(&c.author), + esc(&c.date), + )); + } + body.push_str("\n
shasubjectauthordate
{}{}{}{}
\n"); + } + let html = page(&format!("commits — {site_name}"), nav, &body); + std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?; + std::fs::write(out.join("commits.html"), html) + .map_err(|e| format!("hu13: write commits.html: {e}"))?; + + std::fs::create_dir_all(out.join("commit")) + .map_err(|e| format!("hu13: create commit dir: {e}"))?; + for c in &log { + let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; + let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; + let detail = format!( + "

commit {}

\n

{}

\n

Author: {}
Date: {}

\n

all commits

\n

Message

\n
{}
\n

Diff

\n
{}
\n", + esc(short_sha(&c.sha)), + esc(&c.sha), + esc(&c.author), + esc(&c.date), + esc(&message), + esc(&diff), + ); + let html = page( + &format!("commit {} — {site_name}", short_sha(&c.sha)), + nav, + &detail, + ); + std::fs::write(out.join("commit").join(format!("{}.html", c.sha)), html) + .map_err(|e| format!("hu13: write commit page {}: {e}", short_sha(&c.sha)))?; + } + Ok(log.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn mktemp(prefix: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("hu13-commits-{prefix}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mktemp"); + dir + } + + fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed in {}", repo.display()); + } + + /// Fixture: 3 commits — plain text, evil message + binary change, huge diff. + fn fixture_repo() -> PathBuf { + let dir = mktemp("fixture"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + + // Commit 1: plain text file. + std::fs::write(dir.join("notes.txt"), "line one\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "first commit"]); + + // Commit 2: evil message + text hunk + binary file change. + std::fs::write(dir.join("notes.txt"), "line one\nline two added\n").unwrap(); + let bin: Vec = (0u8..=255).collect(); + std::fs::write(dir.join("bin.dat"), &bin).unwrap(); + git(&dir, &["add", "-A"]); + git( + &dir, + &["commit", "-m", "second commit "], + ); + + // Commit 3: huge diff (>2000 lines). + let mut big = String::new(); + for i in 0..2500 { + big.push_str(&format!("line {i}\n")); + } + std::fs::write(dir.join("big.txt"), &big).unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "third commit huge"]); + + dir + } + + fn shas(repo: &Path) -> Vec { + gitx::log_n(repo, "main", 100) + .expect("log") + .iter() + .map(|c| c.sha.clone()) + .collect() + } + + #[test] + fn list_order_newest_first_with_links() { + let repo = fixture_repo(); + let out = mktemp("out"); + let n = render(&repo, &out, "main", 100, "test", "