#58 Polish batch: dot repo name, gitlinks, inbox id escaping, forge symlinks, wiki index (#68)

mergedpassopened
details
branches
master ← issue-68-polish-batch
issues
db42bcef-a612-436f-9d14-72540f8acf0a
updated
id
6bc2c630-84d9-4ff8-92be-d3601659c2ab

Patch

src/build.rs+15 −2
diff --git a/src/build.rs b/src/build.rsindex 352fc43..d6e3420 100644--- a/src/build.rs+++ b/src/build.rs@@ -68,7 +68,9 @@ pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {6868             return t.to_string();6969         }7070     }71-    repo.file_name()71+    // `hu13 build .` must name the repo, not the path "." (f0rge #68).72+    let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());73+    abs.file_name()7274         .map(|n| n.to_string_lossy().into_owned())7375         .map(|n| n.trim().to_string())7476         .filter(|n| !n.is_empty())@@ -855,6 +857,13 @@ pub fn file_table_html(855857                 esc(&age),856858                 esc(&age)857859             ));860+        } else if e.is_gitlink {861+            // Submodule: no blob in this repo, so no file page/raw copy.862+            out.push_str(&format!(863+                "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\864+                 <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",865+                esc(&e.name),866+            ));858867         } else {859868             out.push_str(&format!(860869                 "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\@@ -1192,7 +1201,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu11921201             };11931202             if e.is_dir {11941203                 queue.push(full);1195-            } else {1204+            } else if !e.is_gitlink {1205+                // Gitlinks have no blob in this repo (f0rge #68).11961206                 files.push(full);11971207             }11981208         }@@ -1259,6 +1269,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu12591269         let mut all_paths: Vec<String> = Vec::new();12601270         for (d, ents) in &dir_entries {12611271             for e in ents {1272+                if e.is_gitlink {1273+                    continue;1274+                }12621275                 let full = if d.is_empty() {12631276                     e.name.clone()12641277                 } else {
src/forge.rs+9 −0
diff --git a/src/forge.rs b/src/forge.rsindex ba988e7..a9ada13 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -60,6 +60,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {6060 /// Read a UTF-8 file, skipping (with a warning) files that are missing,6161 /// oversized, or non-UTF-8. `label` names the file for warnings.6262 fn read_text_capped(path: &Path, label: &str) -> Option<String> {63+    // Never follow symlinks: wiki and release artifacts already skip64+    // them, and `.forge/` is repo-controlled (f0rge #68).65+    match std::fs::symlink_metadata(path) {66+        Ok(m) if m.file_type().is_symlink() => {67+            eprintln!("hu13: forge: skipping {label}: symlink");68+            return None;69+        }70+        _ => {}71+    }6372     let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);6473     if len > FORGE_MAX_BYTES {6574         eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");
src/gitx.rs+5 −1
diff --git a/src/gitx.rs b/src/gitx.rsindex 56acaf5..47910e6 100644--- a/src/gitx.rs+++ b/src/gitx.rs@@ -106,6 +106,9 @@ pub struct TreeEntry {106106     pub name_bytes: Vec<u8>,107107     /// `true` for directories (type `tree`), `false` otherwise.108108     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,109112 }110113 111114 /// Exact bytes of `branch:path` from [`show`].@@ -580,12 +583,13 @@ pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>,580583         // meta: "<mode> <type> <sha>"581584         let meta_str = String::from_utf8_lossy(meta);582585         let mut it = meta_str.split_whitespace();583-        let _mode = it.next().unwrap_or("");586+        let mode = it.next().unwrap_or("");584587         let typ = it.next().unwrap_or("");585588         entries.push(TreeEntry {586589             name: String::from_utf8_lossy(&name_bytes).into_owned(),587590             name_bytes,588591             is_dir: typ == "tree",592+            is_gitlink: mode == "160000",589593         });590594     }591595     entries.sort_by(|a, b| {
src/inbox.rs+1 −1
diff --git a/src/inbox.rs b/src/inbox.rsindex f366558..bc24f3a 100644--- a/src/inbox.rs+++ b/src/inbox.rs@@ -826,7 +826,7 @@ pub fn cmd_list(inbox_dir: &Path) -> i32 {826826     for e in list_entries(inbox_dir) {827827         println!(828828             "{} [{}] {} {}",829-            e.id,829+            escape_terminal(&e.id),830830             escape_terminal(&e.kind),831831             escape_terminal(&e.received_at),832832             escape_terminal(&e.title)
src/releases.rs+9 −0
diff --git a/src/releases.rs b/src/releases.rsindex 129d61a..4ef2d1b 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -93,6 +93,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {9393 }9494 9595 fn read_text_capped(path: &Path, label: &str) -> Option<String> {96+    // Never follow symlinks: wiki and release artifacts already skip97+    // them, and `.forge/` is repo-controlled (f0rge #68).98+    match std::fs::symlink_metadata(path) {99+        Ok(m) if m.file_type().is_symlink() => {100+            eprintln!("hu13: releases: skipping {label}: symlink");101+            return None;102+        }103+        _ => {}104+    }96105     let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);97106     if len > RELEASE_MAX_BYTES {98107         eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");
src/wiki.rs+8 −2
diff --git a/src/wiki.rs b/src/wiki.rsindex 8e332d5..a0e8d7e 100644--- a/src/wiki.rs+++ b/src/wiki.rs@@ -281,11 +281,17 @@ pub fn render(281281     } else {282282         body.push_str("<ul class=\"wiki-index\">\n");283283         for p in &pages {284+            // Root pages already read as "hu13 README"; only nested pages285+            // need the path hint (was "hu13 README README", f0rge #68).286+            let hint = if p.path.contains('/') {287+                format!(" <span class=\"muted\">{}</span>", esc(&p.path))288+            } else {289+                String::new()290+            };284291             body.push_str(&format!(285-                "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",292+                "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",286293                 esc(&p.path),287294                 esc(&p.title),288-                esc(&p.path),289295             ));290296         }291297         body.push_str("</ul>\n");
tests/polish_batch.rs+192 −1
diff --git a/tests/polish_batch.rs b/tests/polish_batch.rsnew file mode 100644index 0000000..5012ddd--- /dev/null+++ b/tests/polish_batch.rs@@ -0,0 +1,192 @@1+//! f0rge #68: small robustness/consistency batch.2+//! - `hu13 build .` names the repo after its directory, not "repo"3+//! - gitlinks (mode 160000) get no raw copy / file page4+//! - `inbox list` escapes a hostile id5+//! - `.forge` symlinks are skipped like wiki/artifact symlinks6+//! - wiki root pages do not repeat their filename after the title7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn fresh(prefix: &str) -> PathBuf {12+    let dir = std::env::temp_dir().join(format!(13+        "hu13-polish-{prefix}-{}-{}",14+        std::process::id(),15+        std::time::SystemTime::now()16+            .duration_since(std::time::UNIX_EPOCH)17+            .map(|d| d.as_nanos())18+            .unwrap_or(0)19+    ));20+    std::fs::create_dir_all(&dir).unwrap();21+    dir22+}23+24+fn git(repo: &Path, args: &[&str]) -> String {25+    let out = Command::new("git")26+        .arg("-C")27+        .arg(repo)28+        .args(args)29+        .output()30+        .expect("spawn git");31+    assert!(32+        out.status.success(),33+        "git {args:?}: {}",34+        String::from_utf8_lossy(&out.stderr)35+    );36+    String::from_utf8_lossy(&out.stdout).trim().to_string()37+}38+39+fn init(repo: &Path) {40+    git(repo, &["init", "-b", "main"]);41+    git(repo, &["config", "user.name", "hu13-test"]);42+    git(repo, &["config", "user.email", "[email protected]"]);43+    git(repo, &["config", "commit.gpgsign", "false"]);44+}45+46+fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output {47+    let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));48+    cmd.arg("build").arg(repo).arg("-o").arg(out);49+    if let Some(dir) = cwd {50+        cmd.current_dir(dir);51+    }52+    cmd.output().expect("spawn hu13 build")53+}54+55+#[test]56+fn dot_build_uses_directory_name() {57+    let parent = fresh("dot");58+    let repo = parent.join("my-project");59+    std::fs::create_dir_all(&repo).unwrap();60+    init(&repo);61+    std::fs::write(repo.join("README.md"), "# dot\n").unwrap();62+    git(&repo, &["add", "-A"]);63+    git(&repo, &["commit", "-m", "one"]);64+    let out = fresh("out");65+    let res = build(Path::new("."), &out, Some(&repo));66+    assert!(67+        res.status.success(),68+        "{}",69+        String::from_utf8_lossy(&res.stderr)70+    );71+    let index = std::fs::read_to_string(out.join("index.html")).unwrap();72+    assert!(73+        index.contains("<h1>my-project</h1>"),74+        "repo named after the directory: {}",75+        &index[..600.min(index.len())]76+    );77+    assert!(!index.contains("<h1>repo</h1>"), "not the literal 'repo'");78+}79+80+#[test]81+fn gitlink_gets_no_raw_or_page() {82+    let repo = fresh("gitlink");83+    init(&repo);84+    std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap();85+    git(&repo, &["add", "-A"]);86+    git(&repo, &["commit", "-m", "one"]);87+    let head = git(&repo, &["rev-parse", "HEAD"]);88+    git(89+        &repo,90+        &[91+            "update-index",92+            "--add",93+            "--cacheinfo",94+            &format!("160000,{head},sub"),95+        ],96+    );97+    // Entry name for --cacheinfo is the last argument.98+    git(&repo, &["commit", "-m", "add gitlink"]);99+    let out = fresh("out");100+    let res = build(&repo, &out, None);101+    assert!(102+        res.status.success(),103+        "{}",104+        String::from_utf8_lossy(&res.stderr)105+    );106+    assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink");107+    assert!(108+        !out.join("files/sub.html").exists(),109+        "no file page for a gitlink"110+    );111+    let index = std::fs::read_to_string(out.join("index.html")).unwrap();112+    assert!(index.contains("@ submodule"), "gitlink marked in the table");113+}114+115+#[test]116+fn inbox_list_escapes_hostile_id() {117+    let inbox = fresh("inbox");118+    // A filename can contain ESC; list must never print it raw.119+    let name = "bad\u{1b}id.json";120+    std::fs::write(121+        inbox.join(name),122+        r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#,123+    )124+    .unwrap();125+    let out = Command::new(env!("CARGO_BIN_EXE_hu13"))126+        .arg("inbox")127+        .arg("list")128+        .arg("--inbox-dir")129+        .arg(&inbox)130+        .output()131+        .unwrap();132+    assert!(out.status.success());133+    let stdout = String::from_utf8_lossy(&out.stdout);134+    assert!(stdout.contains("\\x1b"), "escaped id: {stdout}");135+    assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}");136+}137+138+#[test]139+fn forge_symlink_is_skipped() {140+    let repo = fresh("symlink");141+    init(&repo);142+    std::fs::write(repo.join("README.md"), "# symlink\n").unwrap();143+    let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111");144+    std::fs::create_dir_all(&dir).unwrap();145+    let outside = fresh("outside").join("issue.md");146+    std::fs::write(147+        &outside,148+        "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n",149+    )150+    .unwrap();151+    std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap();152+    git(&repo, &["add", "-A"]);153+    git(&repo, &["commit", "-m", "one"]);154+    let out = fresh("out");155+    let res = build(&repo, &out, None);156+    assert!(157+        res.status.success(),158+        "{}",159+        String::from_utf8_lossy(&res.stderr)160+    );161+    let err = String::from_utf8_lossy(&res.stderr);162+    assert!(err.contains("symlink"), "warned about the symlink: {err}");163+    assert!(164+        !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html")165+            .exists(),166+        "no page for a symlinked issue"167+    );168+}169+170+#[test]171+fn wiki_root_page_does_not_repeat_its_filename() {172+    let repo = fresh("wiki");173+    init(&repo);174+    std::fs::write(repo.join("README.md"), "# wiki\n").unwrap();175+    std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap();176+    std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap();177+    std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap();178+    git(&repo, &["add", "-A"]);179+    git(&repo, &["commit", "-m", "one"]);180+    let out = fresh("out");181+    let res = build(&repo, &out, None);182+    assert!(res.status.success());183+    let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap();184+    assert!(185+        !index.contains("hu13 README</a> README"),186+        "root page path hint dropped: {index}"187+    );188+    assert!(189+        index.contains("Guide</a> <span class=\"muted\">nested/guide.md</span>"),190+        "nested pages keep the path hint"191+    );192+}0-- 2.56.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls