diff --git a/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/patch.diff b/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/patch.diffnew file mode 100644index 0000000..ffefceb--- /dev/null+++ b/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/patch.diff@@ -0,0 +1,387 @@1+From 3b8b3b67caf5e24da01bb856ecf3a25b75a768be Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 16:36:25 +00004+Subject: [PATCH] polish batch: dot repo name, gitlinks, inbox id escaping,5+ forge symlinks, wiki index (#68)6+7+- resolve_repo_name canonicalizes first, so 'hu13 build .' titles the8+ site after the directory instead of the literal 'repo'.9+- TreeEntry carries is_gitlink (mode 160000). Gitlinks get no raw10+ copy, no file page and no `git log -1` lookup; the file table marks11+ them '@ submodule' instead of linking a page that cannot exist.12+- 'inbox list' now runs the id through escape_terminal like every13+ other field (a hostile filename can contain ESC).14+- forge/release read_text_capped refuses symlinked files, matching15+ the wiki and release-artifact rules.16+- wiki index only shows the path hint for nested pages ('hu13 README17+ README' is gone).18+- content-type HEAD anchor was already tightened in #59.19+20+tests/polish_batch.rs covers all five.21+---22+ src/build.rs | 17 +++-23+ src/forge.rs | 9 ++24+ src/gitx.rs | 6 +-25+ src/inbox.rs | 2 +-26+ src/releases.rs | 9 ++27+ src/wiki.rs | 10 ++-28+ tests/polish_batch.rs | 192 ++++++++++++++++++++++++++++++++++++++++++29+ 7 files changed, 239 insertions(+), 6 deletions(-)30+ create mode 100644 tests/polish_batch.rs31+32+diff --git a/src/build.rs b/src/build.rs33+index 352fc43..d6e3420 10064434+--- a/src/build.rs35++++ b/src/build.rs36+@@ -68,7 +68,9 @@ pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {37+ return t.to_string();38+ }39+ }40+- repo.file_name()41++ // `hu13 build .` must name the repo, not the path "." (f0rge #68).42++ let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());43++ abs.file_name()44+ .map(|n| n.to_string_lossy().into_owned())45+ .map(|n| n.trim().to_string())46+ .filter(|n| !n.is_empty())47+@@ -855,6 +857,13 @@ pub fn file_table_html(48+ esc(&age),49+ esc(&age)50+ ));51++ } else if e.is_gitlink {52++ // Submodule: no blob in this repo, so no file page/raw copy.53++ out.push_str(&format!(54++ "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\55++ <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",56++ esc(&e.name),57++ ));58+ } else {59+ out.push_str(&format!(60+ "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\61+@@ -1192,7 +1201,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu62+ };63+ if e.is_dir {64+ queue.push(full);65+- } else {66++ } else if !e.is_gitlink {67++ // Gitlinks have no blob in this repo (f0rge #68).68+ files.push(full);69+ }70+ }71+@@ -1259,6 +1269,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu72+ let mut all_paths: Vec<String> = Vec::new();73+ for (d, ents) in &dir_entries {74+ for e in ents {75++ if e.is_gitlink {76++ continue;77++ }78+ let full = if d.is_empty() {79+ e.name.clone()80+ } else {81+diff --git a/src/forge.rs b/src/forge.rs82+index ba988e7..a9ada13 10064483+--- a/src/forge.rs84++++ b/src/forge.rs85+@@ -60,6 +60,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {86+ /// Read a UTF-8 file, skipping (with a warning) files that are missing,87+ /// oversized, or non-UTF-8. `label` names the file for warnings.88+ fn read_text_capped(path: &Path, label: &str) -> Option<String> {89++ // Never follow symlinks: wiki and release artifacts already skip90++ // them, and `.forge/` is repo-controlled (f0rge #68).91++ match std::fs::symlink_metadata(path) {92++ Ok(m) if m.file_type().is_symlink() => {93++ eprintln!("hu13: forge: skipping {label}: symlink");94++ return None;95++ }96++ _ => {}97++ }98+ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);99+ if len > FORGE_MAX_BYTES {100+ eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");101+diff --git a/src/gitx.rs b/src/gitx.rs102+index 56acaf5..47910e6 100644103+--- a/src/gitx.rs104++++ b/src/gitx.rs105+@@ -106,6 +106,9 @@ pub struct TreeEntry {106+ pub name_bytes: Vec<u8>,107+ /// `true` for directories (type `tree`), `false` otherwise.108+ pub is_dir: bool,109++ /// `true` for submodule entries (mode `160000`): no blob exists, so110++ /// they must never get a raw copy or a file page (f0rge #68).111++ pub is_gitlink: bool,112+ }113+ 114+ /// Exact bytes of `branch:path` from [`show`].115+@@ -580,12 +583,13 @@ pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>,116+ // meta: "<mode> <type> <sha>"117+ let meta_str = String::from_utf8_lossy(meta);118+ let mut it = meta_str.split_whitespace();119+- let _mode = it.next().unwrap_or("");120++ let mode = it.next().unwrap_or("");121+ let typ = it.next().unwrap_or("");122+ entries.push(TreeEntry {123+ name: String::from_utf8_lossy(&name_bytes).into_owned(),124+ name_bytes,125+ is_dir: typ == "tree",126++ is_gitlink: mode == "160000",127+ });128+ }129+ entries.sort_by(|a, b| {130+diff --git a/src/inbox.rs b/src/inbox.rs131+index f366558..bc24f3a 100644132+--- a/src/inbox.rs133++++ b/src/inbox.rs134+@@ -826,7 +826,7 @@ pub fn cmd_list(inbox_dir: &Path) -> i32 {135+ for e in list_entries(inbox_dir) {136+ println!(137+ "{} [{}] {} {}",138+- e.id,139++ escape_terminal(&e.id),140+ escape_terminal(&e.kind),141+ escape_terminal(&e.received_at),142+ escape_terminal(&e.title)143+diff --git a/src/releases.rs b/src/releases.rs144+index 129d61a..4ef2d1b 100644145+--- a/src/releases.rs146++++ b/src/releases.rs147+@@ -93,6 +93,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {148+ }149+ 150+ fn read_text_capped(path: &Path, label: &str) -> Option<String> {151++ // Never follow symlinks: wiki and release artifacts already skip152++ // them, and `.forge/` is repo-controlled (f0rge #68).153++ match std::fs::symlink_metadata(path) {154++ Ok(m) if m.file_type().is_symlink() => {155++ eprintln!("hu13: releases: skipping {label}: symlink");156++ return None;157++ }158++ _ => {}159++ }160+ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);161+ if len > RELEASE_MAX_BYTES {162+ eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");163+diff --git a/src/wiki.rs b/src/wiki.rs164+index 8e332d5..a0e8d7e 100644165+--- a/src/wiki.rs166++++ b/src/wiki.rs167+@@ -281,11 +281,17 @@ pub fn render(168+ } else {169+ body.push_str("<ul class=\"wiki-index\">\n");170+ for p in &pages {171++ // Root pages already read as "hu13 README"; only nested pages172++ // need the path hint (was "hu13 README README", f0rge #68).173++ let hint = if p.path.contains('/') {174++ format!(" <span class=\"muted\">{}</span>", esc(&p.path))175++ } else {176++ String::new()177++ };178+ body.push_str(&format!(179+- "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",180++ "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",181+ esc(&p.path),182+ esc(&p.title),183+- esc(&p.path),184+ ));185+ }186+ body.push_str("</ul>\n");187+diff --git a/tests/polish_batch.rs b/tests/polish_batch.rs188+new file mode 100644189+index 0000000..5012ddd190+--- /dev/null191++++ b/tests/polish_batch.rs192+@@ -0,0 +1,192 @@193++//! f0rge #68: small robustness/consistency batch.194++//! - `hu13 build .` names the repo after its directory, not "repo"195++//! - gitlinks (mode 160000) get no raw copy / file page196++//! - `inbox list` escapes a hostile id197++//! - `.forge` symlinks are skipped like wiki/artifact symlinks198++//! - wiki root pages do not repeat their filename after the title199++200++use std::path::{Path, PathBuf};201++use std::process::Command;202++203++fn fresh(prefix: &str) -> PathBuf {204++ let dir = std::env::temp_dir().join(format!(205++ "hu13-polish-{prefix}-{}-{}",206++ std::process::id(),207++ std::time::SystemTime::now()208++ .duration_since(std::time::UNIX_EPOCH)209++ .map(|d| d.as_nanos())210++ .unwrap_or(0)211++ ));212++ std::fs::create_dir_all(&dir).unwrap();213++ dir214++}215++216++fn git(repo: &Path, args: &[&str]) -> String {217++ let out = Command::new("git")218++ .arg("-C")219++ .arg(repo)220++ .args(args)221++ .output()222++ .expect("spawn git");223++ assert!(224++ out.status.success(),225++ "git {args:?}: {}",226++ String::from_utf8_lossy(&out.stderr)227++ );228++ String::from_utf8_lossy(&out.stdout).trim().to_string()229++}230++231++fn init(repo: &Path) {232++ git(repo, &["init", "-b", "main"]);233++ git(repo, &["config", "user.name", "hu13-test"]);234++ git(repo, &["config", "user.email", "[email protected]"]);235++ git(repo, &["config", "commit.gpgsign", "false"]);236++}237++238++fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output {239++ let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));240++ cmd.arg("build").arg(repo).arg("-o").arg(out);241++ if let Some(dir) = cwd {242++ cmd.current_dir(dir);243++ }244++ cmd.output().expect("spawn hu13 build")245++}246++247++#[test]248++fn dot_build_uses_directory_name() {249++ let parent = fresh("dot");250++ let repo = parent.join("my-project");251++ std::fs::create_dir_all(&repo).unwrap();252++ init(&repo);253++ std::fs::write(repo.join("README.md"), "# dot\n").unwrap();254++ git(&repo, &["add", "-A"]);255++ git(&repo, &["commit", "-m", "one"]);256++ let out = fresh("out");257++ let res = build(Path::new("."), &out, Some(&repo));258++ assert!(259++ res.status.success(),260++ "{}",261++ String::from_utf8_lossy(&res.stderr)262++ );263++ let index = std::fs::read_to_string(out.join("index.html")).unwrap();264++ assert!(265++ index.contains("<h1>my-project</h1>"),266++ "repo named after the directory: {}",267++ &index[..600.min(index.len())]268++ );269++ assert!(!index.contains("<h1>repo</h1>"), "not the literal 'repo'");270++}271++272++#[test]273++fn gitlink_gets_no_raw_or_page() {274++ let repo = fresh("gitlink");275++ init(&repo);276++ std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap();277++ git(&repo, &["add", "-A"]);278++ git(&repo, &["commit", "-m", "one"]);279++ let head = git(&repo, &["rev-parse", "HEAD"]);280++ git(281++ &repo,282++ &[283++ "update-index",284++ "--add",285++ "--cacheinfo",286++ &format!("160000,{head},sub"),287++ ],288++ );289++ // Entry name for --cacheinfo is the last argument.290++ git(&repo, &["commit", "-m", "add gitlink"]);291++ let out = fresh("out");292++ let res = build(&repo, &out, None);293++ assert!(294++ res.status.success(),295++ "{}",296++ String::from_utf8_lossy(&res.stderr)297++ );298++ assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink");299++ assert!(300++ !out.join("files/sub.html").exists(),301++ "no file page for a gitlink"302++ );303++ let index = std::fs::read_to_string(out.join("index.html")).unwrap();304++ assert!(index.contains("@ submodule"), "gitlink marked in the table");305++}306++307++#[test]308++fn inbox_list_escapes_hostile_id() {309++ let inbox = fresh("inbox");310++ // A filename can contain ESC; list must never print it raw.311++ let name = "bad\u{1b}id.json";312++ std::fs::write(313++ inbox.join(name),314++ r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#,315++ )316++ .unwrap();317++ let out = Command::new(env!("CARGO_BIN_EXE_hu13"))318++ .arg("inbox")319++ .arg("list")320++ .arg("--inbox-dir")321++ .arg(&inbox)322++ .output()323++ .unwrap();324++ assert!(out.status.success());325++ let stdout = String::from_utf8_lossy(&out.stdout);326++ assert!(stdout.contains("\\x1b"), "escaped id: {stdout}");327++ assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}");328++}329++330++#[test]331++fn forge_symlink_is_skipped() {332++ let repo = fresh("symlink");333++ init(&repo);334++ std::fs::write(repo.join("README.md"), "# symlink\n").unwrap();335++ let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111");336++ std::fs::create_dir_all(&dir).unwrap();337++ let outside = fresh("outside").join("issue.md");338++ std::fs::write(339++ &outside,340++ "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n",341++ )342++ .unwrap();343++ std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap();344++ git(&repo, &["add", "-A"]);345++ git(&repo, &["commit", "-m", "one"]);346++ let out = fresh("out");347++ let res = build(&repo, &out, None);348++ assert!(349++ res.status.success(),350++ "{}",351++ String::from_utf8_lossy(&res.stderr)352++ );353++ let err = String::from_utf8_lossy(&res.stderr);354++ assert!(err.contains("symlink"), "warned about the symlink: {err}");355++ assert!(356++ !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html")357++ .exists(),358++ "no page for a symlinked issue"359++ );360++}361++362++#[test]363++fn wiki_root_page_does_not_repeat_its_filename() {364++ let repo = fresh("wiki");365++ init(&repo);366++ std::fs::write(repo.join("README.md"), "# wiki\n").unwrap();367++ std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap();368++ std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap();369++ std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap();370++ git(&repo, &["add", "-A"]);371++ git(&repo, &["commit", "-m", "one"]);372++ let out = fresh("out");373++ let res = build(&repo, &out, None);374++ assert!(res.status.success());375++ let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap();376++ assert!(377++ !index.contains("hu13 README</a> README"),378++ "root page path hint dropped: {index}"379++ );380++ assert!(381++ index.contains("Guide</a> <span class=\"muted\">nested/guide.md</span>"),382++ "nested pages keep the path hint"383++ );384++}385+-- 386+2.56.0387+