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>
2026-10-31 07:03:48 +01:00
|
|
|
// 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);
|
feat(run): the repository under review is the freeze record's, not the working directory's
A directory can review repositories other than itself: a workbench whose
config, env file and records serve several, freezing each with --repo and
--out. The run took the working directory for the repository under review:
the prompt named it, the calibration was kept under it, and --trees fell
back to it. The run now reads the repository from the artifact's
freeze.json - the prompt names it and the calibration is kept under it -
and --trees defaults to what the record says: the worktree, else the
repository when it is not the working directory, else `.`, the same rule
that builds the printed run line. The working directory keeps its roles:
the config, the env file, the slots, the lens processes' cwd and every
logged path. A damaged record stops the run at preflight.
2026-10-31 21:31:48 +01:00
|
|
|
// The tree the lenses read for a frozen artifact, as its record says: the
|
|
|
|
|
// worktree (relative to the directory freeze ran from), else the
|
|
|
|
|
// repository when it is not `cwd`, else `.`.
|
|
|
|
|
std::string TreeOf(const Json& record, const std::filesystem::path& cwd);
|
|
|
|
|
// What an artifact's freeze.json says it reviews: the repository, absolute
|
|
|
|
|
// (`cwd` when the artifact has no record, or its record names none), and
|
|
|
|
|
// the tree (TreeOf). A record that is not JSON throws.
|
|
|
|
|
struct Reviewed {
|
|
|
|
|
std::filesystem::path repo;
|
|
|
|
|
std::string tree;
|
|
|
|
|
};
|
|
|
|
|
Reviewed ReviewedBy(const std::filesystem::path& artifact, const std::filesystem::path& cwd);
|
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>
2026-10-31 07:03:48 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|