land #38 hide-empty + f0rge state

4d65321gl0w

all commits

Message

land #38 hide-empty + f0rge state

Diff

.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/comments/be1aa293-ae6b-4be2-a94b-bebd50f0704c.md+6 −0
diff --git a/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/comments/be1aa293-ae6b-4be2-a94b-bebd50f0704c.md b/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/comments/be1aa293-ae6b-4be2-a94b-bebd50f0704c.mdnew file mode 100644index 0000000..1410d0f--- /dev/null+++ b/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/comments/be1aa293-ae6b-4be2-a94b-bebd50f0704c.md@@ -0,0 +1,6 @@1+---2+id: be1aa293-ae6b-4be2-a94b-bebd50f0704c3+author: null4+created: 2026-09-10T07:39:00.079375616Z5+---6+Merged PR 59f46a16 (m6/hide-empty): tasks/releases/wiki tabs hide when empty, deep links still 200, issues/pulls always stay. Verified on empty fixture. CI pass. Rubric verified.
.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/issue.md+2 −2
diff --git a/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/issue.md b/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/issue.mdindex ea34deb..ca11569 100644--- a/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/issue.md+++ b/.forge/issues/2efc4181-21f0-4064-a8e7-bf1bfb925bbd/issue.md@@ -1,13 +1,13 @@11 ---22 id: 2efc4181-21f0-4064-a8e7-bf1bfb925bbd33 title: Hide empty sections from header nav4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T07:30:45.775152978Z10-updated: null10+updated: 2026-09-10T07:39:00.082765377Z1111 ---1212 Context: Owner QA — nav shows tabs for empty sections (e.g. tasks/releases with nothing in them). Dead tabs are noise.1313 Tasks:
.forge/merges/2026-09-10T07-38-01.858080047Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T07-38-01.858080047Z.md b/.forge/merges/2026-09-10T07-38-01.858080047Z.mdnew file mode 100644index 0000000..a80c5c9--- /dev/null+++ b/.forge/merges/2026-09-10T07-38-01.858080047Z.md@@ -0,0 +1,10 @@1+---2+pr: 59f46a16-161c-419f-a9ba-59cfdfb5b7483+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T07:38:01.858080047Z7+---8+applied: 1443670b4c51d7aefbd6f29377872ed40ca8106f9+patch: d8db1673322e780c256e008a1092b2a9eb24e02b10+pass.
.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/patch.diff+362 −0
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+
.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/pr.md+12 −0
diff --git a/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/pr.md b/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/pr.mdnew file mode 100644index 0000000..41c6589--- /dev/null+++ b/.forge/prs/59f46a16-161c-419f-a9ba-59cfdfb5b748/pr.md@@ -0,0 +1,12 @@1+---2+id: 59f46a16-161c-419f-a9ba-59cfdfb5b7483+title: m6/hide-empty4+state: merged5+base: master6+head: m6/hide-empty7+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T07:38:01.827036291Z11+updated: 2026-09-10T07:38:32.238066426Z12+---