diff --git a/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/patch.diff b/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/patch.diffnew file mode 100644index 0000000..cdb5dee--- /dev/null+++ b/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/patch.diff@@ -0,0 +1,362 @@1+From 37d631341ec843514cc6637eb43479bd03c8b56b Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 08:38:01 +01004+Subject: [PATCH] m6/hide-empty: conditional nav tabs, pages still build5+6+---7+ src/build.rs | 61 +++++++++++++-----8+ src/wiki.rs | 90 ++++++++++++++++++++++++++9+ tests/hide_empty.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++10+ 3 files changed, 288 insertions(+), 15 deletions(-)11+ create mode 100644 tests/hide_empty.rs12+13+diff --git a/src/build.rs b/src/build.rs14+index d70dc3f..70aa5d9 10064415+--- a/src/build.rs16++++ b/src/build.rs17+@@ -757,18 +757,37 @@ fn render_text_pre(display: &str) -> String {18+ }19+ 20+ /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:21+-/// open means `state == "open"` exactly; drafts count as open).22+-fn nav_bar(issue_count: usize, pr_count: usize) -> String {23+- format!(24+- "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \25+- <a href=\"/issues/index.html\">issues ({issue_count})</a> · \26+- <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \27+- <a href=\"/tasks/index.html\">tasks</a> · \28+- <a href=\"/ci/index.html\">ci</a> · \29+- <a href=\"/releases/index.html\">releases</a> · \30+- <a href=\"/wiki/index.html\">wiki</a> · \31+- <a href=\"/agents.html\">agents</a>"32+- )33++/// open means `state == "open"` exactly; drafts count as open) and34++/// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /35++/// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`36++/// always stay (they carry the submit path), as do `code` / `commits` /37++/// `ci` / `agents`. Hidden sections still build (deep links keep38++/// working); only the nav omits the tab.39++fn nav_bar(40++ issue_count: usize,41++ pr_count: usize,42++ has_tasks: bool,43++ has_releases: bool,44++ has_wiki: bool,45++) -> String {46++ let mut tabs = vec![47++ "<a href=\"/\">code</a>".to_string(),48++ "<a href=\"/commits.html\">commits</a>".to_string(),49++ format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),50++ format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),51++ ];52++ if has_tasks {53++ tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());54++ }55++ tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());56++ if has_releases {57++ tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());58++ }59++ if has_wiki {60++ tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());61++ }62++ tabs.push("<a href=\"/agents.html\">agents</a>".to_string());63++ tabs.join(" · ")64+ }65+ 66+ /// Write `out/style.css` from the embedded static file.67+@@ -843,9 +862,19 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu68+ // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front69+ // so every page (including file pages below) gets a nav with live70+ // open counts (m6 open-counts, f0rge #29). Loading never fails:71+- // bad files are skipped + warned.72++ // bad files are skipped + warned. Releases load here too (m673++ // hide-empty, f0rge #38) so the nav knows whether the releases tab74++ // has content; wiki presence comes from `wiki::page_count` (same75++ // filters as `wiki::render`, read-only, no files emitted).76+ let forge_data = forge::load(repo);77+- let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());78++ let release_data = releases::load(repo);79++ let nav = nav_bar(80++ forge_data.open_issue_count(),81++ forge_data.open_pr_count(),82++ !forge_data.tasks.is_empty(),83++ !release_data.is_empty(),84++ wiki::page_count(repo) > 0,85++ );86+ 87+ // Raw files first: byte-identical copies.88+ let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();89+@@ -975,8 +1004,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu90+ feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;91+ let wiki_pages = wiki::render(repo, out, &name, &nav, desc_opt)?;92+ // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.93++ // Tasks/releases/wiki indexes always render even when empty (m694++ // hide-empty, f0rge #38): only the nav hides the tab, deep links95++ // keep working with their "No X yet" empty states.96+ forge::render_loaded(&forge_data, out, &name, &nav, desc_opt)?;97+- let release_data = releases::load(repo);98+ releases::render_loaded(&release_data, repo, out, &name, &nav, desc_opt)?;99+ api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;100+ agents::render(101+diff --git a/src/wiki.rs b/src/wiki.rs102+index 4f20af8..02edd5c 100644103+--- a/src/wiki.rs104++++ b/src/wiki.rs105+@@ -66,6 +66,96 @@ pub fn page_title(body: &str, filename: &str) -> String {106+ filename.to_string()107+ }108+ 109++/// Number of renderable wiki pages (m6 hide-empty, f0rge issue #38).110++///111++/// Same walk + filters as [`render`] (symlinked dirs/files skipped,112++/// oversize files skipped, non-UTF-8 files skipped), so the header nav113++/// hides the wiki tab exactly when the index would say "No wiki pages114++/// yet". Read-only: emits no files, only the `eprintln!` warnings that115++/// [`render`] would emit for skipped entries.116++#[must_use]117++pub fn page_count(repo: &Path) -> usize {118++ let root = repo.join(".forge").join("wiki");119++ if !root.exists() {120++ return 0;121++ }122++ // Same walk as `render`: `/`-separated relative dirs, symlinked123++ // dirs never descended, symlinked files skipped later.124++ let mut rel_files: Vec<String> = Vec::new();125++ let mut stack: Vec<String> = vec![String::new()];126++ while let Some(dir) = stack.pop() {127++ let abs = if dir.is_empty() {128++ root.clone()129++ } else {130++ root.join(&dir)131++ };132++ let rd = match std::fs::read_dir(&abs) {133++ Ok(rd) => rd,134++ Err(e) => {135++ eprintln!(136++ "hu13: wiki: cannot list {}: {e}",137++ if dir.is_empty() { "." } else { &dir }138++ );139++ continue;140++ }141++ };142++ for ent in rd {143++ let ent = match ent {144++ Ok(ent) => ent,145++ Err(e) => {146++ eprintln!("hu13: wiki: bad dir entry in {dir}: {e}");147++ continue;148++ }149++ };150++ let meta = match std::fs::symlink_metadata(ent.path()) {151++ Ok(m) => m,152++ Err(e) => {153++ eprintln!(154++ "hu13: wiki: cannot stat {}: {e}",155++ ent.file_name().to_string_lossy()156++ );157++ continue;158++ }159++ };160++ if meta.file_type().is_symlink() {161++ continue;162++ }163++ let name = ent.file_name().to_string_lossy().into_owned();164++ let rel = if dir.is_empty() {165++ name166++ } else {167++ format!("{dir}/{name}")168++ };169++ if meta.is_dir() {170++ stack.push(rel);171++ } else if meta.is_file() {172++ rel_files.push(rel);173++ }174++ }175++ }176++ // Same per-file filters as `render`: oversize + unreadable +177++ // non-UTF-8 files never become pages, so they never keep the tab.178++ let mut count = 0;179++ for rel in rel_files {180++ let abs = root.join(&rel);181++ let len = std::fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);182++ if len > WIKI_MAX_BYTES {183++ continue;184++ }185++ let bytes = match std::fs::read(&abs) {186++ Ok(b) => b,187++ Err(_) => continue,188++ };189++ if bytes.len() as u64 > WIKI_MAX_BYTES {190++ continue;191++ }192++ if std::str::from_utf8(&bytes).is_err() {193++ continue;194++ }195++ count += 1;196++ }197++ count198++}199+ /// Render `wiki/index.html` plus one `wiki/<path>.html` per page.200+ ///201+ /// A missing `<repo>/.forge/wiki/` dir renders an empty index ("No wiki202+diff --git a/tests/hide_empty.rs b/tests/hide_empty.rs203+new file mode 100644204+index 0000000..5b19f01205+--- /dev/null206++++ b/tests/hide_empty.rs207+@@ -0,0 +1,152 @@208++//! Conditional nav tabs (m6 hide-empty, f0rge issue #38):209++//! `tasks` / `releases` / `wiki` render in the header ONLY when non-empty;210++//! `issues` / `pulls` / `ci` / `code` / `commits` / `agents` always stay.211++//! Hidden sections still build (deep links keep working with their212++//! "No X yet" empty states); only the nav omits the tab.213++214++use std::path::{Path, PathBuf};215++use std::process::Command;216++217++fn bin() -> &'static str {218++ env!("CARGO_BIN_EXE_hu13")219++}220++221++fn fresh(prefix: &str) -> PathBuf {222++ let dir = std::env::temp_dir().join(format!(223++ "hu13-hide-empty-{prefix}-{}-{}",224++ std::process::id(),225++ std::time::SystemTime::now()226++ .duration_since(std::time::UNIX_EPOCH)227++ .map(|d| d.as_nanos())228++ .unwrap_or(0)229++ ));230++ std::fs::create_dir_all(&dir).unwrap();231++ dir232++}233++234++fn git(repo: &Path, args: &[&str]) {235++ let st = Command::new("git")236++ .arg("-C")237++ .arg(repo)238++ .args(args)239++ .status()240++ .expect("spawn git");241++ assert!(st.success(), "git {args:?} failed");242++}243++244++fn forge_write(repo: &Path, rel: &str, content: &str) {245++ let dest = repo.join(".forge").join(rel);246++ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();247++ std::fs::write(&dest, content).unwrap();248++}249++250++fn bare_repo() -> PathBuf {251++ let dir = fresh("repo");252++ git(&dir, &["init", "-b", "main"]);253++ git(&dir, &["config", "user.name", "hu13-test"]);254++ git(&dir, &["config", "user.email", "[email protected]"]);255++ git(&dir, &["config", "commit.gpgsign", "false"]);256++ std::fs::write(dir.join("README.md"), "# hide-empty fixture\n").unwrap();257++ git(&dir, &["add", "-A"]);258++ git(&dir, &["commit", "-m", "init"]);259++ dir260++}261++262++fn build(repo: &Path) -> PathBuf {263++ let out = fresh("out");264++ let st = Command::new(bin())265++ .arg("build")266++ .arg(repo)267++ .arg("-o")268++ .arg(&out)269++ .status()270++ .expect("spawn hu13 build");271++ assert!(st.success(), "build exits 0");272++ out273++}274++275++fn header_of(out: &Path, rel: &str) -> String {276++ let html = std::fs::read_to_string(out.join(rel)).expect(rel);277++ html.split("</header>")278++ .next()279++ .expect("header close")280++ .to_string()281++}282++283++const ALWAYS_TABS: [&str; 6] = [284++ "<a href=\"/\"",285++ "<a href=\"/commits.html\"",286++ "<a href=\"/issues/index.html\"",287++ "<a href=\"/pulls/index.html\"",288++ "<a href=\"/ci/index.html\"",289++ "<a href=\"/agents.html\"",290++];291++292++const CONDITIONAL_TABS: [(&str, &str); 3] = [293++ ("tasks", "<a href=\"/tasks/index.html\""),294++ ("releases", "<a href=\"/releases/index.html\""),295++ ("wiki", "<a href=\"/wiki/index.html\""),296++];297++298++#[test]299++fn empty_sections_hide_tabs_but_pages_still_build() {300++ let out = build(&bare_repo());301++ // Empty-state pages still build (deep links keep working).302++ for (page, marker) in [303++ ("tasks/index.html", "No tasks yet"),304++ ("releases/index.html", "No releases yet"),305++ ("wiki/index.html", "No wiki pages yet"),306++ ] {307++ let html = std::fs::read_to_string(out.join(page)).expect(page);308++ assert!(html.contains(marker), "{page} keeps its empty state");309++ }310++ // Every sampled header hides the three tabs but keeps the rest.311++ for page in ["index.html", "commits.html", "tasks/index.html"] {312++ let header = header_of(&out, page);313++ for (name, needle) in CONDITIONAL_TABS {314++ assert!(!header.contains(needle), "{page} header hides empty {name}");315++ }316++ for needle in ALWAYS_TABS {317++ assert!(318++ header.contains(needle),319++ "{page} header keeps always-shown {needle}"320++ );321++ }322++ }323++}324++325++#[test]326++fn full_forge_shows_all_tabs() {327++ let repo = bare_repo();328++ forge_write(329++ &repo,330++ "tasks/77777777-7777-4777-8777-777777777777.md",331++ "---\nid: 77777777-7777-4777-8777-777777777777\ntitle: A task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",332++ );333++ forge_write(334++ &repo,335++ "releases/v1.0.md",336++ "---\nversion: v1.0\ntitle: First\ncreated: 2026-09-10T02:00:00Z\n---\n# v1.0 notes\n",337++ );338++ forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");339++ git(&repo, &["add", "-A"]);340++ git(&repo, &["commit", "-m", "full forge"]);341++ let out = build(&repo);342++ let header = header_of(&out, "index.html");343++ for (name, needle) in CONDITIONAL_TABS {344++ assert!(345++ header.contains(needle),346++ "populated {name} tab shown in header"347++ );348++ }349++ for needle in ALWAYS_TABS {350++ assert!(header.contains(needle), "header keeps {needle}");351++ }352++ // Populated pages list their content.353++ let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");354++ assert!(tasks.contains("A task"), "task listed");355++ let releases = std::fs::read_to_string(out.join("releases/index.html")).expect("releases");356++ assert!(releases.contains("v1.0"), "release listed");357++ let wiki = std::fs::read_to_string(out.join("wiki/index.html")).expect("wiki");358++ assert!(wiki.contains("Notes"), "wiki page listed");359++}360+-- 361+2.55.0362+