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

View file

@ -4,10 +4,12 @@
module Crafter.Review:Driver_impl;
import :Driver;
import :Core;
import :Text;
import :Platform;
import :Process;
import :Inventory;
import :Config;
import :Freeze;
import std;
namespace fs = std::filesystem;
@ -33,10 +35,175 @@ inventory unit, against a frozen commit range.)";
return 0;
}
std::int32_t UsageError(std::string_view message) {
std::println(std::cerr, "{}\ncrafter-review: error: {}", Usage, message);
// A usage error: exit 3, the preflight's code, for every command that
// cannot start.
std::int32_t UsageError(std::string_view usage, std::string_view prog, std::string_view message) {
std::println(std::cerr, "{}\n{}: error: {}", usage, prog, message);
return 3;
}
// die(): the preflight's message and exit 3.
std::int32_t Die(std::string_view message) {
std::println(std::cerr, "preflight: {}", message);
return 3;
}
// ---- the option parser these commands use ---------------------------------
struct OptionSpec {
std::string_view name;
// A value follows (`--x V` or `--x=V`); else a store_true flag.
bool takesValue;
std::string_view metavar;
std::string_view help;
};
struct Parsed {
std::map<std::string, std::string, std::less<>> values;
std::set<std::string, std::less<>> flags;
bool help = false;
std::string error;
std::optional<std::string> Value(std::string_view name) const {
const auto found = values.find(name);
if (found == values.end()) return std::nullopt;
return found->second;
}
bool Flag(std::string_view name) const {
return flags.contains(name);
}
};
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
std::string line = std::format("usage: {} [-h]", prog);
for (const OptionSpec& spec : specs) {
line += spec.takesValue ? std::format(" [{} {}]", spec.name, spec.metavar) : std::format(" [{}]", spec.name);
}
return line;
}
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs) {
std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description);
for (const OptionSpec& spec : specs) {
const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help);
}
return text;
}
// `--opt value`, `--opt=value`, any unique prefix of a long option, the
// last of a repeated option, every unrecognized argument named at the
// end.
Parsed ParseOptions(std::span<const std::string_view> args, std::span<const OptionSpec> specs) {
Parsed parsed;
std::vector<std::string> unrecognized;
for (std::size_t i = 0; i < args.size(); ++i) {
const std::string_view arg = args[i];
if (arg == "-h") {
parsed.help = true;
continue;
}
if (!arg.starts_with("--") || arg == "--") {
unrecognized.emplace_back(arg);
continue;
}
const std::size_t equals = arg.find('=');
const std::string_view name = arg.substr(0, equals);
if (name.size() > 2 && std::string_view("--help").starts_with(name) && std::ranges::none_of(specs, [&](const OptionSpec& s) { return s.name.starts_with(name); })) {
parsed.help = true;
continue;
}
std::vector<const OptionSpec*> matches;
for (const OptionSpec& spec : specs) {
if (spec.name == name) {
matches = {&spec};
break;
}
if (spec.name.starts_with(name)) matches.push_back(&spec);
}
if (matches.empty()) {
unrecognized.emplace_back(arg);
continue;
}
if (matches.size() > 1) {
std::vector<std::string> names;
for (const OptionSpec* spec : matches) names.emplace_back(spec->name);
parsed.error = std::format("ambiguous option: {} could match {}", name, Dyn::Join(names, ", "));
return parsed;
}
const OptionSpec& spec = *matches.front();
const std::string key(spec.name.substr(2));
if (!spec.takesValue) {
if (equals != std::string_view::npos) {
parsed.error = std::format("argument {}: ignored explicit argument {}", spec.name, Dyn::Repr(arg.substr(equals + 1)));
return parsed;
}
parsed.flags.insert(key);
continue;
}
if (equals != std::string_view::npos) {
parsed.values[key] = std::string(arg.substr(equals + 1));
} else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) {
parsed.values[key] = std::string(args[++i]);
} else {
parsed.error = std::format("argument {}: expected one argument", spec.name);
return parsed;
}
}
if (!unrecognized.empty()) parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " "));
return parsed;
}
// The consumer config, loaded before every command but `config`:
// warnings on stderr, a problem stops it.
std::optional<Json> LoadConfig(const std::optional<std::string>& configPath) {
Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get<std::string>());
if (!loaded.problems.empty()) {
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
return std::nullopt;
}
return std::move(loaded.config);
}
constexpr std::array<OptionSpec, 9> FreezeOptions = {{
{"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"},
{"--slug", true, "SLUG", "the artifact dir name: .review-gates/push/<slug>/"},
{"--out", true, "OUT", "the artifact dir (overrides --slug)"},
{"--base", true, "BASE", "the range base; default @{upstream}, else merge-base with the configured base branch"},
{"--replace", false, "", "re-freeze into an out dir that already holds patches (gate-state/ is kept) and supersede this repo's worktrees"},
{"--worktree", false, "", "check out a detached worktree of HEAD under .review-gates/trees/ for the lenses (printed as --trees)"},
{"--require-signed", false, "", "an unsigned commit fails instead of warning"},
{"--date", true, "DATE", "the run date (default today)"},
{"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"},
}};
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
constexpr std::string_view Prog = "crafter-review freeze";
const Parsed parsed = ParseOptions(args, FreezeOptions);
if (parsed.help) {
std::print("{}", Help(Prog, "cut the frozen commit-range artifact for the push gates (process/prepush.md Gate 0)", FreezeOptions));
return 0;
}
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, FreezeOptions), Prog, parsed.error);
const std::optional<Json> config = LoadConfig(configPath);
if (!config) return 3;
Freeze::Arguments arguments;
arguments.repo = parsed.Value("repo");
arguments.slug = parsed.Value("slug");
arguments.out = parsed.Value("out");
arguments.base = parsed.Value("base");
arguments.replace = parsed.Flag("replace");
arguments.worktree = parsed.Flag("worktree");
arguments.requireSigned = parsed.Flag("require-signed");
arguments.date = parsed.Value("date").value_or(Platform::TodayIso());
arguments.verify = parsed.Flag("verify");
// `%G?` starts gpg, which can leave a daemon behind: as a subreaper
// this process inherits it, and takes it down on the way out.
Process::BecomeSubreaper();
const std::int32_t code = Freeze::Main(arguments, *config);
Process::KillDescendants();
return code;
}
}
std::int32_t Main(std::span<const std::string_view> args) {
@ -60,7 +227,7 @@ inventory unit, against a frozen commit range.)";
} else if (at + 1 < rest.size()) {
configPath = std::string(rest[++at]);
} else {
return UsageError("argument --config: expected one argument");
return UsageError(Usage, "crafter-review", "argument --config: expected one argument");
}
} else if (name.size() > 2 && std::string_view("--version").starts_with(name)) {
return PrintVersion();
@ -68,7 +235,7 @@ inventory unit, against a frozen commit range.)";
std::println("{}", Usage);
return 0;
} else {
return UsageError(std::format("unrecognized arguments: {}", arg));
return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", arg));
}
++at;
}
@ -76,14 +243,21 @@ inventory unit, against a frozen commit range.)";
std::println("{}", Usage);
return 0;
}
if (at >= rest.size()) return UsageError("a command is required");
if (at >= rest.size()) return UsageError(Usage, "crafter-review", "a command is required");
const std::string_view command = rest[at];
const std::span<const std::string_view> after = rest.subspan(at + 1);
if (command == "inventory" && !configPath) return Inventory::Main(after);
if (command == "config") {
if (!after.empty()) return UsageError(std::format("unrecognized arguments: {}", after.front()));
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
try {
if (command == "inventory" && !configPath) return Inventory::Main(after);
if (command == "config") {
if (!after.empty()) return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", after.front()));
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
}
if (command == "freeze") return FreezeCommand(after, configPath);
} catch (const Dyn::Error& error) {
// An error no command catches: its message, exit 1.
std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what());
return 1;
}
return UsageError(std::format("argument cmd: invalid choice: '{}'", command));
return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
}
}