diff --git a/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/patch.diff b/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/patch.diffnew file mode 100644index 0000000..7e353d5--- /dev/null+++ b/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/patch.diff@@ -0,0 +1,210 @@1+From bf318cc0b0135d25dda693a634148fa1848b52a7 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 15:35:29 +00004+Subject: [PATCH] start every build from an empty out/ (#67)5+6+Pages for deleted issues/files kept being served when -o was reused.7+prepare_out() removes the output dir first, refuses to delete the8+repo or any ancestor of it (-o ., -o ..), and removes a symlinked9+output with remove_file (never follows it). Branch resolution runs10+before the clean so a bad --branch does not destroy the old build.11+---12+ src/build.rs | 50 +++++++++++++++++++13+ tests/clean_out.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++14+ 2 files changed, 169 insertions(+)15+ create mode 100644 tests/clean_out.rs16+17+diff --git a/src/build.rs b/src/build.rs18+index 5ca0e2a..90e6651 10064419+--- a/src/build.rs20++++ b/src/build.rs21+@@ -843,11 +843,61 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {22+ Ok(())23+ }24+ 25++/// Absolute lexical form of `p` (no symlink resolution beyond what the26++/// OS already did for existing components).27++fn absolute(p: &Path) -> std::path::PathBuf {28++ if p.is_absolute() {29++ p.to_path_buf()30++ } else {31++ std::env::current_dir()32++ .map(|cwd| cwd.join(p))33++ .unwrap_or_else(|_| p.to_path_buf())34++ }35++}36++37++/// Remove everything already in `out/` before a build (f0rge #67).38++///39++/// Without this, pages for deleted issues/files keep being served from a40++/// reused output dir. Refuses to delete the repo itself or anything that41++/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir42++/// without following it.43++fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {44++ let out_abs = absolute(out);45++ let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));46++ if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {47++ return Err(format!(48++ "hu13: refusing to clean output dir {}: it contains the repo",49++ out.display()50++ ));51++ }52++ if out_abs.parent().is_none() {53++ return Err("hu13: refusing to use the filesystem root as output dir".to_string());54++ }55++ match std::fs::symlink_metadata(&out_abs) {56++ Ok(meta) if meta.file_type().is_symlink() => {57++ std::fs::remove_file(&out_abs)58++ .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;59++ }60++ Ok(meta) if meta.is_dir() => {61++ std::fs::remove_dir_all(&out_abs)62++ .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;63++ }64++ Ok(_) => {65++ std::fs::remove_file(&out_abs)66++ .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;67++ }68++ Err(_) => {}69++ }70++ std::fs::create_dir_all(&out_abs)71++ .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))72++}73++74+ /// Build the full static site. Returns a summary on success.75+ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {76+ let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())77+ .map_err(|e| format!("hu13: {e}"))?;78+ let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;79++ prepare_out(repo, out)?;80+ let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());81+ let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());82+ 83+diff --git a/tests/clean_out.rs b/tests/clean_out.rs84+new file mode 10064485+index 0000000..5288f5686+--- /dev/null87++++ b/tests/clean_out.rs88+@@ -0,0 +1,119 @@89++//! f0rge #67: a reused `-o DIR` must not keep serving pages whose source90++//! (issue, file, release) was deleted. Every build starts from an empty91++//! output dir, and the guard refuses to delete the repo or its ancestors.92++93++use std::path::{Path, PathBuf};94++use std::process::Command;95++96++fn git(repo: &Path, args: &[&str]) {97++ let st = Command::new("git")98++ .arg("-C")99++ .arg(repo)100++ .args(args)101++ .status()102++ .expect("spawn git");103++ assert!(st.success(), "git {args:?} failed");104++}105++106++fn fresh_dir(prefix: &str) -> PathBuf {107++ let dir = std::env::temp_dir().join(format!(108++ "hu13-clean-{prefix}-{}-{}",109++ std::process::id(),110++ std::time::SystemTime::now()111++ .duration_since(std::time::UNIX_EPOCH)112++ .map(|d| d.as_nanos())113++ .unwrap_or(0)114++ ));115++ std::fs::create_dir_all(&dir).unwrap();116++ dir117++}118++119++fn build(repo: &Path, out: &Path) -> std::process::Output {120++ Command::new(env!("CARGO_BIN_EXE_hu13"))121++ .arg("build")122++ .arg(repo)123++ .arg("-o")124++ .arg(out)125++ .output()126++ .expect("spawn hu13 build")127++}128++129++#[test]130++fn rebuild_removes_stale_pages() {131++ let repo = fresh_dir("repo");132++ let out = fresh_dir("out");133++ git(&repo, &["init", "-b", "main"]);134++ git(&repo, &["config", "user.name", "hu13-test"]);135++ git(&repo, &["config", "user.email", "[email protected]"]);136++ git(&repo, &["config", "commit.gpgsign", "false"]);137++ std::fs::write(repo.join("README.md"), "# clean\n").unwrap();138++ std::fs::write(repo.join("keep.txt"), "keep\n").unwrap();139++ let issue = repo.join(".forge/issues/99999999-9999-4999-8999-999999999999");140++ std::fs::create_dir_all(&issue).unwrap();141++ std::fs::write(142++ issue.join("issue.md"),143++ "---\nid: 99999999-9999-4999-8999-999999999999\ntitle: gone soon\n\144++ state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",145++ )146++ .unwrap();147++ git(&repo, &["add", "-A"]);148++ git(&repo, &["commit", "-m", "one"]);149++150++ let first = build(&repo, &out);151++ assert!(152++ first.status.success(),153++ "first build: {}",154++ String::from_utf8_lossy(&first.stderr)155++ );156++ let page = out.join("issues/99999999-9999-4999-8999-999999999999.html");157++ assert!(page.is_file(), "issue page written");158++ assert!(out.join("raw/keep.txt").is_file());159++160++ // Delete the issue + a file, rebuild into the same out dir.161++ std::fs::remove_dir_all(repo.join(".forge/issues/99999999-9999-4999-8999-999999999999"))162++ .unwrap();163++ std::fs::remove_file(repo.join("keep.txt")).unwrap();164++ std::fs::write(repo.join("README.md"), "# clean two\n").unwrap();165++ git(&repo, &["add", "-A"]);166++ git(&repo, &["commit", "-m", "two"]);167++168++ let second = build(&repo, &out);169++ assert!(170++ second.status.success(),171++ "second build: {}",172++ String::from_utf8_lossy(&second.stderr)173++ );174++ assert!(!page.exists(), "stale issue page must be gone");175++ assert!(176++ !out.join("raw/keep.txt").exists(),177++ "stale raw file must be gone"178++ );179++ assert!(out.join("raw/README.md").is_file(), "fresh output present");180++}181++182++#[test]183++fn refuses_to_clean_repo_or_its_parent() {184++ let repo = fresh_dir("repo");185++ git(&repo, &["init", "-b", "main"]);186++ git(&repo, &["config", "user.name", "hu13-test"]);187++ git(&repo, &["config", "user.email", "[email protected]"]);188++ git(&repo, &["config", "commit.gpgsign", "false"]);189++ std::fs::write(repo.join("README.md"), "# keep\n").unwrap();190++ git(&repo, &["add", "-A"]);191++ git(&repo, &["commit", "-m", "one"]);192++ let sender = repo.join("src.txt");193++ std::fs::write(&sender, "must survive\n").unwrap();194++195++ for out in [repo.clone(), repo.parent().unwrap().to_path_buf()] {196++ let res = build(&repo, &out);197++ assert!(198++ !res.status.success(),199++ "must refuse to clean {}",200++ out.display()201++ );202++ let err = String::from_utf8_lossy(&res.stderr);203++ assert!(err.contains("refusing"), "stderr: {err}");204++ assert!(repo.join("README.md").is_file(), "repo untouched");205++ assert!(sender.is_file(), "repo untouched");206++ }207++}208+-- 209+2.56.0210+