#47 Start every build from an empty out/ (#67)
Patch
src/build.rs+50 −0--- 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 −1--- /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+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.