start every build from an empty out/ (#67)

2749d01gl0w

all commits

Message

start every build from an empty out/ (#67)

Pages for deleted issues/files kept being served when -o was reused.
prepare_out() removes the output dir first, refuses to delete the
repo or any ancestor of it (-o ., -o ..), and removes a symlinked
output with remove_file (never follows it). Branch resolution runs
before the clean so a bad --branch does not destroy the old build.

Diff

src/build.rs+50 −0
diff --git a/src/build.rs b/src/build.rsindex 5ca0e2a..90e6651 100644--- a/src/build.rs+++ b/src/build.rs@@ -843,11 +843,61 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {843843     Ok(())844844 }845845 846+/// Absolute lexical form of `p` (no symlink resolution beyond what the847+/// OS already did for existing components).848+fn absolute(p: &Path) -> std::path::PathBuf {849+    if p.is_absolute() {850+        p.to_path_buf()851+    } else {852+        std::env::current_dir()853+            .map(|cwd| cwd.join(p))854+            .unwrap_or_else(|_| p.to_path_buf())855+    }856+}857+858+/// Remove everything already in `out/` before a build (f0rge #67).859+///860+/// Without this, pages for deleted issues/files keep being served from a861+/// reused output dir. Refuses to delete the repo itself or anything that862+/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir863+/// without following it.864+fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {865+    let out_abs = absolute(out);866+    let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));867+    if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {868+        return Err(format!(869+            "hu13: refusing to clean output dir {}: it contains the repo",870+            out.display()871+        ));872+    }873+    if out_abs.parent().is_none() {874+        return Err("hu13: refusing to use the filesystem root as output dir".to_string());875+    }876+    match std::fs::symlink_metadata(&out_abs) {877+        Ok(meta) if meta.file_type().is_symlink() => {878+            std::fs::remove_file(&out_abs)879+                .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;880+        }881+        Ok(meta) if meta.is_dir() => {882+            std::fs::remove_dir_all(&out_abs)883+                .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;884+        }885+        Ok(_) => {886+            std::fs::remove_file(&out_abs)887+                .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;888+        }889+        Err(_) => {}890+    }891+    std::fs::create_dir_all(&out_abs)892+        .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))893+}894+846895 /// Build the full static site. Returns a summary on success.847896 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {848897     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())849898         .map_err(|e| format!("hu13: {e}"))?;850899     let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;900+    prepare_out(repo, out)?;851901     let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());852902     let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());853903 
tests/clean_out.rs+119 −0
diff --git a/tests/clean_out.rs b/tests/clean_out.rsnew file mode 100644index 0000000..5288f56--- /dev/null+++ b/tests/clean_out.rs@@ -0,0 +1,119 @@1+//! f0rge #67: a reused `-o DIR` must not keep serving pages whose source2+//! (issue, file, release) was deleted. Every build starts from an empty3+//! output dir, and the guard refuses to delete the repo or its ancestors.4+5+use std::path::{Path, PathBuf};6+use std::process::Command;7+8+fn git(repo: &Path, args: &[&str]) {9+    let st = Command::new("git")10+        .arg("-C")11+        .arg(repo)12+        .args(args)13+        .status()14+        .expect("spawn git");15+    assert!(st.success(), "git {args:?} failed");16+}17+18+fn fresh_dir(prefix: &str) -> PathBuf {19+    let dir = std::env::temp_dir().join(format!(20+        "hu13-clean-{prefix}-{}-{}",21+        std::process::id(),22+        std::time::SystemTime::now()23+            .duration_since(std::time::UNIX_EPOCH)24+            .map(|d| d.as_nanos())25+            .unwrap_or(0)26+    ));27+    std::fs::create_dir_all(&dir).unwrap();28+    dir29+}30+31+fn build(repo: &Path, out: &Path) -> std::process::Output {32+    Command::new(env!("CARGO_BIN_EXE_hu13"))33+        .arg("build")34+        .arg(repo)35+        .arg("-o")36+        .arg(out)37+        .output()38+        .expect("spawn hu13 build")39+}40+41+#[test]42+fn rebuild_removes_stale_pages() {43+    let repo = fresh_dir("repo");44+    let out = fresh_dir("out");45+    git(&repo, &["init", "-b", "main"]);46+    git(&repo, &["config", "user.name", "hu13-test"]);47+    git(&repo, &["config", "user.email", "[email protected]"]);48+    git(&repo, &["config", "commit.gpgsign", "false"]);49+    std::fs::write(repo.join("README.md"), "# clean\n").unwrap();50+    std::fs::write(repo.join("keep.txt"), "keep\n").unwrap();51+    let issue = repo.join(".forge/issues/99999999-9999-4999-8999-999999999999");52+    std::fs::create_dir_all(&issue).unwrap();53+    std::fs::write(54+        issue.join("issue.md"),55+        "---\nid: 99999999-9999-4999-8999-999999999999\ntitle: gone soon\n\56+         state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",57+    )58+    .unwrap();59+    git(&repo, &["add", "-A"]);60+    git(&repo, &["commit", "-m", "one"]);61+62+    let first = build(&repo, &out);63+    assert!(64+        first.status.success(),65+        "first build: {}",66+        String::from_utf8_lossy(&first.stderr)67+    );68+    let page = out.join("issues/99999999-9999-4999-8999-999999999999.html");69+    assert!(page.is_file(), "issue page written");70+    assert!(out.join("raw/keep.txt").is_file());71+72+    // Delete the issue + a file, rebuild into the same out dir.73+    std::fs::remove_dir_all(repo.join(".forge/issues/99999999-9999-4999-8999-999999999999"))74+        .unwrap();75+    std::fs::remove_file(repo.join("keep.txt")).unwrap();76+    std::fs::write(repo.join("README.md"), "# clean two\n").unwrap();77+    git(&repo, &["add", "-A"]);78+    git(&repo, &["commit", "-m", "two"]);79+80+    let second = build(&repo, &out);81+    assert!(82+        second.status.success(),83+        "second build: {}",84+        String::from_utf8_lossy(&second.stderr)85+    );86+    assert!(!page.exists(), "stale issue page must be gone");87+    assert!(88+        !out.join("raw/keep.txt").exists(),89+        "stale raw file must be gone"90+    );91+    assert!(out.join("raw/README.md").is_file(), "fresh output present");92+}93+94+#[test]95+fn refuses_to_clean_repo_or_its_parent() {96+    let repo = fresh_dir("repo");97+    git(&repo, &["init", "-b", "main"]);98+    git(&repo, &["config", "user.name", "hu13-test"]);99+    git(&repo, &["config", "user.email", "[email protected]"]);100+    git(&repo, &["config", "commit.gpgsign", "false"]);101+    std::fs::write(repo.join("README.md"), "# keep\n").unwrap();102+    git(&repo, &["add", "-A"]);103+    git(&repo, &["commit", "-m", "one"]);104+    let sender = repo.join("src.txt");105+    std::fs::write(&sender, "must survive\n").unwrap();106+107+    for out in [repo.clone(), repo.parent().unwrap().to_path_buf()] {108+        let res = build(&repo, &out);109+        assert!(110+            !res.status.success(),111+            "must refuse to clean {}",112+            out.display()113+        );114+        let err = String::from_utf8_lossy(&res.stderr);115+        assert!(err.contains("refusing"), "stderr: {err}");116+        assert!(repo.join("README.md").is_file(), "repo untouched");117+        assert!(sender.is_file(), "repo untouched");118+    }119+}