feat(freeze): crafter-review freeze, and --verify that names a git upgrade

Freeze: identity, AI trailers by the consumer's rule (the pattern with
case-insensitive folds and its \s* that crosses lines), a clean tree, no
merges, a fast-forward, then format-patch, the hash, the dispositions
register and the optional detached worktree; --verify at push time. The
command line takes --opt=value, unique prefixes and the last of a repeat,
with a usage error at exit 3 like every command that cannot start; the
config is loaded and its warnings and problems reported first. The run
line it prints names this binary.

freeze.json records gitVersion. format-patch ends every patch with the
git version, so a fresh export under another git hashes differently;
--verify's MISMATCH says which git exported the artifact and which the
fresh copy, reading the patches' own signature when freeze.json does not
record it. freeze becomes a subreaper and takes down whatever gpg left
running for %G?.

tests/Freeze: 18 checks, plus the gitVersion record and both MISMATCH
messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 07:03:48 +01:00
commit 61acd6a72e
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 943 additions and 13 deletions

176
tests/Freeze/main.cpp Normal file
View file

@ -0,0 +1,176 @@
// 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 <stdlib.h>
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<std::string> parts;
for (const Json& item : list) parts.push_back(item.get<std::string>());
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<std::string>().contains(part); });
}
bool AnyContains(const std::vector<std::string>& 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<std::string, std::string> 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<std::string> args) {
std::vector<std::string> 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<std::string> 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 <noreply@anthropic.com>", "three\n");
commit("foreign", "four\n", "a.txt", "Cat Bot <catbot@bot.local>");
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});
std::string failures = Joined(r.at("failures"));
Check("trailer (forbid) + foreign author both named, nothing exported; the author list came from git config", !r.at("ok").get<bool>() && failures.contains("Co-Authored-By")
&& failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures);
r = freeze({.base = base, .aiTrailers = "allow"});
Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get<bool>() && 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, .authors = {me, "catbot@bot.local"}, .aiTrailers = "allow"});
Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get<bool>() && 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<bool>()
&& 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<bool>());
Check("re-freeze without --replace refused", !freeze({.base = base}).at("ok").get<bool>());
const Json r0 = freeze({.replace = true});
Check("no --base, no upstream, no config base -> a failure that says so", !r0.at("ok").get<bool>() && r0.at("failures").at(0).get<std::string>().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<bool>() && r1.at("base") == base && r1.at("baseFrom").get<std::string>().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<bool>() && ru.at("baseFrom") == "@{upstream}" && ru.at("files").size() == 1 && rb.at("ok").get<bool>()
&& rb.at("baseFrom").get<std::string>().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<std::string>()))
&& 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<bool>() && r2.at("worktree").get<std::string>().starts_with(std::format("{}/r-", Freeze::Worktrees))
&& Artifact::TreeHead(wt) == clean && Artifact::IsToplevel(wt), Joined(r2.at("failures")));
std::vector<std::string> 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<std::string> allowA = {"a.txt"};
Check("dirty tree: freeze refuses, verify mismatches", !freeze({.base = base, .replace = true}).at("ok").get<bool>() && !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<bool>() && 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<bool>() && AnyContains(r3.at("failures"), "merge commit"));
g({"worktree", "remove", "--force", wt.string()});
fs::remove_all(td);
return Failures ? 1 : 0;
}