record #68 merge

fd89957gl0w

all commits

Message

record #68 merge

Diff

.forge/merges/2026-10-27T16-37-20.368744112Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T16-37-20.368744112Z.md b/.forge/merges/2026-10-27T16-37-20.368744112Z.mdnew file mode 100644index 0000000..9b34e99--- /dev/null+++ b/.forge/merges/2026-10-27T16-37-20.368744112Z.md@@ -0,0 +1,9 @@1+---2+pr: 6bc2c630-84d9-4ff8-92be-d3601659c2ab3+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T16:37:20.368744112Z7+---8+clean apply9+patch: f54c99aebff2bd538f93b30f2b2b2e8aa60d5e4f
.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/patch.diff+387 −0
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+
.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/pr.md+13 −0
diff --git a/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/pr.md b/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/pr.mdnew file mode 100644index 0000000..301564b--- /dev/null+++ b/.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/pr.md@@ -0,0 +1,13 @@1+---2+id: 6bc2c630-84d9-4ff8-92be-d3601659c2ab3+title: 'Polish batch: dot repo name, gitlinks, inbox id escaping, forge symlinks, wiki index (#68)'4+state: merged5+base: master6+head: issue-68-polish-batch7+draft: false8+issues:9+- db42bcef-a612-436f-9d14-72540f8acf0a10+ci: pass11+created: 2026-10-27T16:36:25.445360108Z12+updated: 2026-10-27T16:37:20.368744112Z13+---