.forge/prs/b1df12f8-4e73-41df-8011-067cf01697b4/patch.diff 7.7 KiB 210 lines Raw
1 From 2749d01e0fde0b0d6842704eff4ec0c77ae77d99 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