merge #67 clean out

d3bf411gl0w

all commits

Message

merge #67 clean out

Diff

.forge/merges/2026-10-27T15-36-07.385524611Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T15-36-07.385524611Z.md b/.forge/merges/2026-10-27T15-36-07.385524611Z.mdnew file mode 100644index 0000000..270aa87--- /dev/null+++ b/.forge/merges/2026-10-27T15-36-07.385524611Z.md@@ -0,0 +1,9 @@1+---2+pr: b1df12f8-4e73-41df-8011-067cf01697b43+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T15:36:07.385524611Z7+---8+clean apply9+patch: 7da4f6b2e4a1b9487ab5bb1f91d02f135c2e4692
.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/patch.diff+210 −0
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+
.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/pr.md+13 −0
diff --git a/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/pr.md b/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/pr.mdnew file mode 100644index 0000000..47b15dd--- /dev/null+++ b/.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/pr.md@@ -0,0 +1,13 @@1+---2+id: b1df12f8-4e73-41df-8011-067cf01697b43+title: Start every build from an empty out/ (#67)4+state: merged5+base: master6+head: issue-67-clean-out7+draft: false8+issues:9+- 158745a1-039d-4d34-b51a-1453268693ea10+ci: pass11+created: 2026-10-27T15:35:29.665007696Z12+updated: 2026-10-27T15:36:07.385524611Z13+---