diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cfd3d..babab65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,7 +95,12 @@ different event from one that moved because it was wrong. the registry imported all four adapters at startup (#47). An adapter is now imported the first time it is created, and one that cannot be says which module is missing; the other adapters, and the adapters list, are unaffected. - +- `scripts/build_site.py --out` deleted whatever directory it was given unless + it was the repository, `site/`, or above them, so a typo such as `--out docs` + or `--out .git` deleted source or history (#50). It now deletes only a + directory that is empty or carries the `.plumbline-site` marker a build + writes, and refuses anything else before doing any work. A `_site` built + before this change has no marker, so it is refused once; remove it by hand. - A row whose response named no model was never priced, because pricing looked up only the model reported; the guard, which prices the requested model, and the report then disagreed about the same run (#36). Such a row is diff --git a/scripts/build_site.py b/scripts/build_site.py index 9579a08..ada3380 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -858,15 +858,42 @@ def search_index(rendered: dict[str, Rendered], explainer: str) -> list[dict[str return entries +#: Written into every site this script builds. The output directory is deleted +#: before a build, so only one that carries this, or holds nothing, is deleted. +MARKER = ".plumbline-site" + + +def refusal_to_clear(out: Path) -> str | None: + """Why ``out`` must not be deleted and rebuilt, or None when it may be. + + A path that does not exist, an empty directory, and a directory an earlier + build wrote may be cleared. Anything else is somebody's files, and a typo in + --out would otherwise delete the source, the history, or a home directory. + """ + out = out.resolve() + if out in (ROOT, SITE) or out in ROOT.parents or SITE in out.parents: + return f"{out} is the repository, the site sources, or above them" + if not out.exists(): + return None + if not out.is_dir(): + return f"{out} is a file, not a directory" + if (out / MARKER).is_file() or not any(out.iterdir()): + return None + return ( + f"{out} is not a site this build made (it has files and no {MARKER}), so it " + "is not deleted. Choose a new or empty directory, or remove this one yourself." + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--out", type=Path, default=ROOT / "_site", help="output directory") args = parser.parse_args() out: Path = args.out.resolve() - # The output directory is deleted and rebuilt, so it must be a directory of - # its own and never the repository, the site sources, or anything above them. - if out in (ROOT, SITE) or out in ROOT.parents or SITE in out.parents: - print(f"site build refused: {out} is not a safe output directory", file=sys.stderr) + # The output directory is deleted and rebuilt, so it is checked before any + # work is done. + if (refusal := refusal_to_clear(out)) is not None: + print(f"site build refused: {refusal}", file=sys.stderr) return 1 try: @@ -885,6 +912,10 @@ def main() -> int: # vendor/ holds the markdown renderer, which runs here at build time; the # pages it produces are finished HTML, so it is not shipped. shutil.copytree(SITE, out, ignore=shutil.ignore_patterns("vendor")) + (out / MARKER).write_text( + "Built by scripts/build_site.py, which deletes this directory on its next build.\n", + encoding="utf-8", + ) (out / "example-run.json").write_text(json.dumps(example, indent=1) + "\n", encoding="utf-8") (out / "index.html").write_text(explainer, encoding="utf-8") (out / "search-index.json").write_text( diff --git a/tests/test_build_site.py b/tests/test_build_site.py index 0c89ee2..636986c 100644 --- a/tests/test_build_site.py +++ b/tests/test_build_site.py @@ -184,3 +184,35 @@ def test_search_index_caps_long_sections(site: ModuleType) -> None: rendered[site.DOCS[0].slug]["sections"][2]["text"] = "x" * 10_000 index = site.search_index(rendered, EXPLAINER) assert max(len(entry["x"]) for entry in index) == site.SEARCH_TEXT_LIMIT + + +def test_out_refuses_a_directory_the_build_did_not_make(site: ModuleType, tmp_path: Path) -> None: + # The output directory is deleted before the build writes it, so a typo in + # --out must never reach source, history, or home. + for protected in (site.ROOT, site.SITE, site.ROOT / "docs", site.ROOT / "src"): + assert site.refusal_to_clear(protected) is not None + assert site.refusal_to_clear(site.ROOT / ".git") is not None + assert site.refusal_to_clear(Path.home()) is not None + + ours = tmp_path / "keep" + ours.mkdir() + (ours / "notes.txt").write_text("mine", encoding="utf-8") + assert "not a site this build made" in site.refusal_to_clear(ours) + + a_file = tmp_path / "a-file" + a_file.write_text("x", encoding="utf-8") + assert site.refusal_to_clear(a_file) is not None + + +def test_out_accepts_new_empty_or_previously_built_directories( + site: ModuleType, tmp_path: Path +) -> None: + assert site.refusal_to_clear(tmp_path / "new") is None + empty = tmp_path / "empty" + empty.mkdir() + assert site.refusal_to_clear(empty) is None + built = tmp_path / "built" + built.mkdir() + (built / site.MARKER).write_text("", encoding="utf-8") + (built / "index.html").write_text("

old

", encoding="utf-8") + assert site.refusal_to_clear(built) is None