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

@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:Freeze;
import std;
import :Json;
// `crafter-review freeze`: cut the frozen commit-range artifact for the push
// gates — identity, AI trailers by the consumer's rule, a clean tree, no
// merges, a fast-forward — then `git format-patch` it, hash it, and
// optionally check out a detached worktree the lenses read; with --verify,
// prove at push time that the repository still is what was reviewed.
//
// freeze.json records gitVersion, the git that exported the patches:
// format-patch ends every patch with its version, so a fresh export under
// another git hashes differently, and --verify says so when that is why.
export namespace Crafter::Review::Freeze {
// .review-gates/trees: inside the consumer's checkout, gitignored, so no
// --add-dir is needed for the lenses to read a worktree there.
constexpr std::string_view Worktrees = ".review-gates/trees";
constexpr std::string_view PushDir = ".review-gates/push";
// `git --version` without its "git version " prefix; "" when git does not
// answer.
std::string GitVersion();
struct Commit {
std::string sha;
std::string author;
std::string committer;
std::string sig;
std::string subject;
std::string body;
};
// base..HEAD, oldest first. Throws Dyn::Error (RuntimeError) with git's
// message.
std::vector<Commit> CommitsIn(const std::filesystem::path& repo, std::string_view base);
// The tracked changes that count, and the ones the config allows
// (allowDirty): `XY path` lines of git status --porcelain.
std::pair<std::vector<std::string>, std::vector<std::string>> DirtyPaths(const std::filesystem::path& repo, std::span<const std::string> allowDirty = {});
struct Base {
std::string sha;
std::optional<std::string> upstream;
std::string how;
std::string failure;
};
// An explicit --base wins; with rangeFrom 'base' the merge-base of HEAD and
// the configured branch (the whole pull request); else the branch's
// @{upstream} (the push delta), else that merge-base (a new branch that
// tracks nothing yet).
Base ResolveBase(const std::filesystem::path& repo, std::string_view base, const std::optional<std::string>& configBase, std::string_view rangeFrom);
struct Options {
std::string base;
bool replace = false;
bool worktree = false;
bool requireSigned = false;
std::vector<std::string> authors;
std::string aiTrailers = "forbid";
std::vector<std::string> allowDirty;
std::optional<std::string> configBase;
std::string rangeFrom = "upstream";
std::string date;
std::filesystem::path cwd;
};
// The report: ok, repo, out, date, failures, warnings, then base, baseFrom,
// upstream, head, authors, aiTrailers, commits, files, hash, worktree as
// they come. Nothing is exported while a check fails; every check is
// listed, not just the first.
Json Run(const std::filesystem::path& repo, const std::filesystem::path& out, const Options& options);
// Push-time check: HEAD is the frozen head, the tree is clean (the allowed
// paths aside), a fresh export hashes to the frozen hash, and the artifact
// dir still does too. The problems; none: it matches.
std::vector<std::string> Verify(const std::filesystem::path& repo, const std::filesystem::path& out, std::span<const std::string> allowDirty = {});
// The exact `run` command for a freeze report: built from its fields, never
// retyped (2026-09-29).
std::string RunLine(const Json& report, std::string_view outRel, std::string_view trees);
struct Arguments {
std::optional<std::string> repo;
std::optional<std::string> slug;
std::optional<std::string> out;
std::optional<std::string> base;
bool replace = false;
bool worktree = false;
bool requireSigned = false;
std::string date;
bool verify = false;
};
// `crafter-review freeze ...` with the consumer's config loaded: 0, 1 for
// a --verify MISMATCH, 3 for a failed check.
std::int32_t Main(const Arguments& arguments, const Json& config);
}