// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Freeze: the checks (authors, trailers by rule, allowDirty, the config // base), the export, the run line, verify, on a scratch repository, and a // MISMATCH that comes from a change of git version saying so. #include import std; import Crafter.Review; namespace fs = std::filesystem; using namespace Crafter::Review; namespace { std::int32_t Failures = 0; void Check(std::string_view name, bool cond, std::string_view detail = {}) { std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); if (!cond) ++Failures; } std::string Joined(const Json& list) { std::vector parts; for (const Json& item : list) parts.push_back(item.get()); return Dyn::Join(parts, " | "); } bool AnyContains(const Json& list, std::string_view part) { return std::ranges::any_of(list, [&](const Json& item) { return item.get().contains(part); }); } bool AnyContains(const std::vector& list, std::string_view part) { return std::ranges::any_of(list, [&](const std::string& item) { return item.contains(part); }); } } int main() { std::println("freeze: checks (authors, trailers by rule, allowDirty, the config base), export, run line, verify on a temp repo"); // No git of this test reads a global config: this machine's signs every // commit with the owner's key. setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1); setenv("GIT_CONFIG_SYSTEM", "/dev/null", 1); const fs::path td = fs::temp_directory_path() / std::format("crafter-review-freeze-{}", Platform::PublishedPid()); fs::remove_all(td); const fs::path repo = td / "r"; fs::create_directories(repo); const std::string me = "me@example.com"; std::map env = Platform::Environment(); env["GIT_AUTHOR_NAME"] = "Me"; env["GIT_AUTHOR_EMAIL"] = me; env["GIT_COMMITTER_NAME"] = "Me"; env["GIT_COMMITTER_EMAIL"] = me; auto g = [&](std::vector args) { std::vector argv = {"git", "-C", repo.string()}; std::ranges::move(args, std::back_inserter(argv)); const Process::Captured run = Process::Run(argv, std::nullopt, std::nullopt, {}, env); if (run.returnCode != 0) throw std::runtime_error(std::format("{}: {}", Dyn::Join(argv, " "), run.err)); return Dyn::Strip(run.out); }; auto commit = [&](const std::string& message, std::string_view text, const std::string& path = "a.txt", const std::string& author = {}) { std::ofstream(repo / path, std::ios::app) << text; g({"add", path}); std::vector args = {"commit", "-q", "-m", message}; if (!author.empty()) args.push_back(std::format("--author={}", author)); g(args); return g({"rev-parse", "HEAD"}); }; g({"init", "-q", "-b", "main"}); g({"config", "user.email", me}); g({"config", "user.name", "Me"}); const std::string base = commit("base", "one\n"); g({"branch", "dev"}); const std::string clean = commit("clean change", "two\n"); commit("with trailer\n\nCo-Authored-By: Claude ", "three\n"); commit("foreign", "four\n", "a.txt", "Cat Bot "); const fs::path out = td / "out"; auto freeze = [&](Freeze::Options options) { options.cwd = td; if (options.date.empty()) options.date = "2026-10-31"; return Freeze::Run(repo, out, options); }; Json r = freeze({.base = base, .authors = std::vector {me}}); std::string failures = Joined(r.at("failures")); Check("trailer (forbid) + foreign author both named, nothing exported", !r.at("ok").get() && failures.contains("Co-Authored-By") && failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures); r = freeze({.base = base, .authors = std::vector {me}, .aiTrailers = "allow"}); Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get() && Joined(r.at("failures")).contains("catbot") && !Joined(r.at("failures")).contains("Co-Authored-By") && AnyContains(r.at("warnings"), "Co-Authored-By")); r = freeze({.base = base}); Check("no authors (null in the config): author and committer unchecked, said in a warning - only the trailer fails", !r.at("ok").get() && Joined(r.at("failures")).contains("Co-Authored-By") && !Joined(r.at("failures")).contains("catbot") && r.at("authors").is_null() && AnyContains(r.at("warnings"), "author and committer not checked"), Joined(r.at("failures"))); r = freeze({.base = base, .authors = std::vector {me, "catbot@bot.local"}, .aiTrailers = "allow"}); Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get() && r.at("files").size() == 3, Joined(r.at("failures"))); g({"reset", "-q", "--hard", clean}); r = freeze({.base = base, .replace = true}); Check("clean range: exported 1 file, hash = artifact_hash, head = HEAD, register created, base from --base", r.at("ok").get() && r.at("files") == Json::array({"0001-clean-change.patch"}) && r.at("hash") == Artifact::ArtifactHash("commits", out) && r.at("head") == clean && Artifact::PatchHead(out) == clean && Dyn::LoadJsonFile(out / "gate-state" / "dispositions.json") == Json::array() && r.at("baseFrom") == "--base", Joined(r.at("failures"))); Check("no upstream -> warning only; unsigned -> warning, failure with require_signed", AnyContains(r.at("warnings"), "no upstream") && AnyContains(r.at("warnings"), "not GPG-signed") && !freeze({.base = base, .replace = true, .requireSigned = true}).at("ok").get()); Check("re-freeze without --replace refused", !freeze({.base = base}).at("ok").get()); const Json r0 = freeze({.replace = true}); Check("no --base, no upstream, no config base -> a failure that says so", !r0.at("ok").get() && r0.at("failures").at(0).get().contains("no base in .review-gates/config.json")); Json r1 = freeze({.replace = true, .configBase = "dev"}); Check("no upstream: the merge-base with the config base is the base", r1.at("ok").get() && r1.at("base") == base && r1.at("baseFrom").get().starts_with("merge-base HEAD dev")); g({"checkout", "-q", "-b", "topic"}); g({"remote", "add", "origin", repo.string()}); g({"update-ref", "refs/remotes/origin/topic", clean}); g({"branch", "--set-upstream-to=origin/topic", "topic"}); commit("more", "more\n"); const Json ru = freeze({.replace = true, .configBase = "dev"}); const Json rb = freeze({.replace = true, .configBase = "dev", .rangeFrom = "base"}); Check("rangeFrom: upstream = the push delta (1 commit from origin/topic); base = the whole branch from the merge-base with dev (2 commits), with a warning that it is not the delta", ru.at("ok").get() && ru.at("baseFrom") == "@{upstream}" && ru.at("files").size() == 1 && rb.at("ok").get() && rb.at("baseFrom").get().starts_with("merge-base HEAD dev (config rangeFrom base)") && rb.at("files").size() == 2 && rb.at("base") == base && AnyContains(rb.at("warnings"), "whole range"), DumpCompactSorted({Dyn::Get(ru, "baseFrom"), Dyn::Get(rb, "baseFrom"), Dyn::Get(rb, "failures")})); g({"checkout", "-q", "main"}); g({"branch", "-D", "topic"}); g({"update-ref", "-d", "refs/remotes/origin/topic"}); g({"remote", "remove", "origin"}); r1 = freeze({.replace = true, .configBase = "dev"}); const std::string line = Freeze::RunLine(r1, "out", "."); Check("run line: built from the report, names the hash, the base, the tree and the register", line.starts_with(std::format("{} run --artifact out --hash {}", Invocation(), r1.at("hash").get())) && line.contains(std::format("--base {}", base)) && line.contains("--trees .") && line.contains("out/gate-state/dispositions.json")); const Json r2 = freeze({.base = base, .replace = true, .worktree = true}); const fs::path wt = td / Dyn::Str(Dyn::Get(r2, "worktree")); Check("--worktree checks out HEAD detached under .review-gates/trees", r2.at("ok").get() && r2.at("worktree").get().starts_with(std::format("{}/r-", Freeze::Worktrees)) && Artifact::TreeHead(wt) == clean && Artifact::IsToplevel(wt), Joined(r2.at("failures"))); std::vector problems = Freeze::Verify(repo, out); Check("verify: MATCH right after the freeze", problems.empty(), Dyn::Join(problems, "; ")); Check("freeze.json records the git that exported the patches", Dyn::LoadJsonFile(out / "freeze.json").at("gitVersion") == Freeze::GitVersion() && !Freeze::GitVersion().empty()); std::ofstream(repo / "a.txt", std::ios::app) << "dirty\n"; const std::vector allowA = {"a.txt"}; Check("dirty tree: freeze refuses, verify mismatches", !freeze({.base = base, .replace = true}).at("ok").get() && !Freeze::Verify(repo, out).empty()); const Json rd = freeze({.base = base, .replace = true, .allowDirty = allowA}); Check("allowDirty: the dirty path is allowed with a warning; verify passes with it too", rd.at("ok").get() && AnyContains(rd.at("warnings"), "allowDirty") && Freeze::Verify(repo, out, allowA).empty() && !Freeze::Verify(repo, out).empty()); const auto [counted, allowed] = Freeze::DirtyPaths(repo, allowA); Check("dirty_paths splits counted and allowed", allowed.size() == 1 && Dyn::Slice(allowed[0], 3) == "a.txt" && counted.empty()); g({"checkout", "-q", "--", "a.txt"}); // A git upgrade between the freeze and the push: the artifact signed by // another version, its hash and freeze.json made to agree with it. { const std::string now = Freeze::GitVersion(); std::string all; for (const std::string& name : Artifact::PatchFiles(out)) { std::string text = *Platform::ReadFile(out / name); text = Dyn::Replace(text, std::format("-- \n{}\n", now), "-- \n1.0.0\n"); std::ofstream(out / name, std::ios::binary | std::ios::trunc) << text; } Json frozen = Dyn::LoadJsonFile(out / "freeze.json"); frozen["hash"] = Artifact::ArtifactHash("commits", out); frozen["gitVersion"] = "1.0.0"; std::ofstream(out / "freeze.json", std::ios::binary | std::ios::trunc) << DumpFile(frozen); problems = Freeze::Verify(repo, out); Check("verify after a git upgrade: the MISMATCH says which git exported the artifact and which the fresh copy", problems.size() == 1 && problems[0].contains(std::format("git 1.0.0 exported the artifact and git {} the fresh copy", now)), Dyn::Join(problems, "; ")); frozen.erase("gitVersion"); std::ofstream(out / "freeze.json", std::ios::binary | std::ios::trunc) << DumpFile(frozen); problems = Freeze::Verify(repo, out); Check("...and a freeze.json without gitVersion: the patches' signature names it", problems.size() == 1 && problems[0].contains("git 1.0.0 exported the artifact"), Dyn::Join(problems, "; ")); freeze({.base = base, .replace = true}); } g({"commit", "-q", "--amend", "-m", "clean change, amended"}); problems = Freeze::Verify(repo, out); Check("verify after an amend: MISMATCH names HEAD and the export", !problems.empty() && AnyContains(problems, "HEAD") && AnyContains(problems, "fresh export")); g({"checkout", "-q", "-b", "side", base}); commit("side", "side\n", "b.txt"); g({"checkout", "-q", "main"}); g({"merge", "-q", "--no-ff", "-m", "merge side", "side"}); const Json r3 = freeze({.base = base, .replace = true}); Check("merge in the range refused", !r3.at("ok").get() && AnyContains(r3.at("failures"), "merge commit")); // What a run reads from the record: the repository under review and the // tree the lenses read (a workbench freezing another repository with // --repo: the record names it, not the working directory). const Json withTree = {{"repo", repo.string()}, {"worktree", "x/y"}}; const Json repoOnly = {{"repo", repo.string()}}; Check("TreeOf: the record's worktree, else the repository when it is not the working directory, else `.`", Freeze::TreeOf(withTree, td) == "x/y" && Freeze::TreeOf(repoOnly, td) == repo.string() && Freeze::TreeOf(repoOnly, repo) == "." && Freeze::TreeOf(Json::object(), td) == "."); const Freeze::Reviewed byRecord = Freeze::ReviewedBy(out, td); const fs::path none = td / "no-record"; fs::create_directories(none); const Freeze::Reviewed byNone = Freeze::ReviewedBy(none, td); std::ofstream(none / "freeze.json", std::ios::binary) << "{broken"; bool damaged = false; try { Freeze::ReviewedBy(none, td); } catch (const std::runtime_error& e) { damaged = std::string(e.what()).contains("not a freeze record"); } Check("ReviewedBy: the record's repository and its tree; no record: the working directory and `.`; a damaged record throws, saying so", fs::weakly_canonical(byRecord.repo) == fs::weakly_canonical(repo) && byRecord.tree == Dyn::AbsPath(repo.string()) && Freeze::ReviewedBy(out, repo).tree == "." && byNone.repo == td && byNone.tree == "." && damaged, std::format("{} {} {}", byRecord.repo.string(), byRecord.tree, byNone.tree)); g({"worktree", "remove", "--force", wt.string()}); fs::remove_all(td); return Failures ? 1 : 0; }