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:
parent
71dba5690f
commit
61acd6a72e
6 changed files with 943 additions and 13 deletions
|
|
@ -4,10 +4,12 @@
|
||||||
module Crafter.Review:Driver_impl;
|
module Crafter.Review:Driver_impl;
|
||||||
import :Driver;
|
import :Driver;
|
||||||
import :Core;
|
import :Core;
|
||||||
|
import :Text;
|
||||||
import :Platform;
|
import :Platform;
|
||||||
import :Process;
|
import :Process;
|
||||||
import :Inventory;
|
import :Inventory;
|
||||||
import :Config;
|
import :Config;
|
||||||
|
import :Freeze;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
@ -33,10 +35,175 @@ inventory unit, against a frozen commit range.)";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::int32_t UsageError(std::string_view message) {
|
// A usage error: exit 3, the preflight's code, for every command that
|
||||||
std::println(std::cerr, "{}\ncrafter-review: error: {}", Usage, message);
|
// 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;
|
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) {
|
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()) {
|
} else if (at + 1 < rest.size()) {
|
||||||
configPath = std::string(rest[++at]);
|
configPath = std::string(rest[++at]);
|
||||||
} else {
|
} 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)) {
|
} else if (name.size() > 2 && std::string_view("--version").starts_with(name)) {
|
||||||
return PrintVersion();
|
return PrintVersion();
|
||||||
|
|
@ -68,7 +235,7 @@ inventory unit, against a frozen commit range.)";
|
||||||
std::println("{}", Usage);
|
std::println("{}", Usage);
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
return UsageError(std::format("unrecognized arguments: {}", arg));
|
return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", arg));
|
||||||
}
|
}
|
||||||
++at;
|
++at;
|
||||||
}
|
}
|
||||||
|
|
@ -76,14 +243,21 @@ inventory unit, against a frozen commit range.)";
|
||||||
std::println("{}", Usage);
|
std::println("{}", Usage);
|
||||||
return 0;
|
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::string_view command = rest[at];
|
||||||
const std::span<const std::string_view> after = rest.subspan(at + 1);
|
const std::span<const std::string_view> after = rest.subspan(at + 1);
|
||||||
|
try {
|
||||||
if (command == "inventory" && !configPath) return Inventory::Main(after);
|
if (command == "inventory" && !configPath) return Inventory::Main(after);
|
||||||
if (command == "config") {
|
if (command == "config") {
|
||||||
if (!after.empty()) return UsageError(std::format("unrecognized arguments: {}", after.front()));
|
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());
|
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
||||||
}
|
}
|
||||||
return UsageError(std::format("argument cmd: invalid choice: '{}'", command));
|
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(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
482
implementations/Crafter.Review-Freeze.cpp
Normal file
482
implementations/Crafter.Review-Freeze.cpp
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module Crafter.Review:Freeze_impl;
|
||||||
|
import :Freeze;
|
||||||
|
import :Json;
|
||||||
|
import :Text;
|
||||||
|
import :Core;
|
||||||
|
import :Crypto;
|
||||||
|
import :Artifact;
|
||||||
|
import :Config;
|
||||||
|
import :Platform;
|
||||||
|
import :Process;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace Crafter::Review::Freeze {
|
||||||
|
namespace {
|
||||||
|
struct GitRun {
|
||||||
|
std::int32_t code = 0;
|
||||||
|
std::string out;
|
||||||
|
std::string err;
|
||||||
|
};
|
||||||
|
|
||||||
|
// git -C <repo> ..., both outputs captured as text: UTF-8 with
|
||||||
|
// universal newlines.
|
||||||
|
GitRun Git(const std::string& repo, std::vector<std::string> args) {
|
||||||
|
std::vector<std::string> argv = {"git", "-C", repo};
|
||||||
|
std::ranges::move(args, std::back_inserter(argv));
|
||||||
|
const Process::Captured run = Process::Run(argv);
|
||||||
|
return {run.returnCode, Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out)), Dyn::UniversalNewlines(Dyn::DecodeReplace(run.err))};
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ASCII word at `at`, case-insensitively as re.I reads it.
|
||||||
|
bool WordAt(std::u32string_view text, std::size_t at, std::string_view word) {
|
||||||
|
if (at + word.size() > text.size()) return false;
|
||||||
|
for (std::size_t i = 0; i < word.size(); ++i) {
|
||||||
|
const char p = word[i];
|
||||||
|
if (p >= 'a' && p <= 'z') {
|
||||||
|
if (Dyn::IgnoreCaseLetter(text[at + i]) != p) return false;
|
||||||
|
} else if (text[at + i] != static_cast<char32_t>(p)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI_TRAILER.search(body).group(0), the pattern
|
||||||
|
// ^\s*(assisted-by|co-authored-by)\s*: with re.I and re.M: the first
|
||||||
|
// line start from which whitespace (newlines too), the word,
|
||||||
|
// whitespace and a colon follow.
|
||||||
|
std::optional<std::string> AiTrailer(std::string_view body) {
|
||||||
|
const std::u32string text = Dyn::Decode(body);
|
||||||
|
for (std::size_t start = 0; start <= text.size();) {
|
||||||
|
std::size_t at = start;
|
||||||
|
while (at < text.size() && Dyn::IsSpace(text[at])) ++at;
|
||||||
|
for (const std::string_view word : {std::string_view("assisted-by"), std::string_view("co-authored-by")}) {
|
||||||
|
if (!WordAt(text, at, word)) continue;
|
||||||
|
std::size_t end = at + word.size();
|
||||||
|
while (end < text.size() && Dyn::IsSpace(text[end])) ++end;
|
||||||
|
if (end < text.size() && text[end] == U':') return Dyn::Encode(std::u32string_view(text).substr(start, end + 1 - start));
|
||||||
|
}
|
||||||
|
const std::size_t newline = text.find(U'\n', start);
|
||||||
|
if (newline == std::u32string::npos) break;
|
||||||
|
start = newline + 1;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Head12(std::string_view text) {
|
||||||
|
return Dyn::Slice(text, 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Strings(const Json& list) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
if (!list.is_array()) return out;
|
||||||
|
for (const Json& item : list) out.push_back(Dyn::Str(item));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The version format-patch signed the artifact's last patch with: the
|
||||||
|
// line after its last `-- `.
|
||||||
|
std::string PatchGitVersion(const fs::path& out) {
|
||||||
|
const std::vector<std::string> files = Artifact::PatchFiles(out);
|
||||||
|
if (files.empty()) return "";
|
||||||
|
const std::optional<std::string> bytes = Platform::ReadFile(out / files.back());
|
||||||
|
if (!bytes) return "";
|
||||||
|
const std::vector<std::string> lines = Dyn::Split(Dyn::UniversalNewlines(Dyn::DecodeReplace(*bytes)), "\n");
|
||||||
|
for (std::size_t i = lines.size(); i-- > 1;) {
|
||||||
|
if (lines[i - 1] == "-- ") return Dyn::Strip(lines[i]);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// tempfile.TemporaryDirectory(): a fresh directory, removed again.
|
||||||
|
class TemporaryDirectory {
|
||||||
|
public:
|
||||||
|
TemporaryDirectory() {
|
||||||
|
for (;;) {
|
||||||
|
path = fs::temp_directory_path() / std::format("tmp{}", TokenHex(4));
|
||||||
|
if (fs::create_directory(path)) break;
|
||||||
|
}
|
||||||
|
fs::permissions(path, fs::perms::owner_all, fs::perm_options::replace);
|
||||||
|
}
|
||||||
|
~TemporaryDirectory() {
|
||||||
|
std::error_code error;
|
||||||
|
fs::remove_all(path, error);
|
||||||
|
}
|
||||||
|
TemporaryDirectory(const TemporaryDirectory&) = delete;
|
||||||
|
TemporaryDirectory& operator=(const TemporaryDirectory&) = delete;
|
||||||
|
fs::path path;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string GitVersion() {
|
||||||
|
try {
|
||||||
|
const Process::Captured run = Process::Run({"git", "--version"});
|
||||||
|
const std::string text = Dyn::Strip(Dyn::DecodeReplace(run.out));
|
||||||
|
constexpr std::string_view Prefix = "git version ";
|
||||||
|
return run.returnCode == 0 && text.starts_with(Prefix) ? text.substr(Prefix.size()) : std::string();
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Commit> CommitsIn(const fs::path& repo, std::string_view base) {
|
||||||
|
const GitRun log = Git(repo.string(), {"log", "--reverse", "--format=%H%x00%ae%x00%ce%x00%G?%x00%s%x00%B%x1e", std::format("{}..HEAD", base)});
|
||||||
|
if (log.code != 0) throw Dyn::Error("RuntimeError", Dyn::Strip(log.err));
|
||||||
|
std::vector<Commit> out;
|
||||||
|
for (const std::string& record : Dyn::Split(log.out, "\x1e")) {
|
||||||
|
if (Dyn::Strip(record).empty()) continue;
|
||||||
|
const std::vector<std::string> fields = Dyn::Split(Dyn::Strip(record, "\n"), std::string_view("\0", 1), 5);
|
||||||
|
if (fields.size() != 6) throw Dyn::Error("ValueError", std::format("not enough values to unpack (expected 6, got {})", fields.size()));
|
||||||
|
out.push_back({fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::pair<std::vector<std::string>, std::vector<std::string>> DirtyPaths(const fs::path& repo, std::span<const std::string> allowDirty) {
|
||||||
|
const GitRun status = Git(repo.string(), {"status", "--porcelain", "--untracked-files=no"});
|
||||||
|
std::vector<std::string> counted;
|
||||||
|
std::vector<std::string> allowed;
|
||||||
|
for (const std::string& line : Dyn::Split(Dyn::RStrip(status.out, "\n"), "\n")) {
|
||||||
|
if (Dyn::Strip(line).empty()) continue;
|
||||||
|
// `XY path`: the two status columns and a space, not stripped: a
|
||||||
|
// leading space is a column.
|
||||||
|
const std::string path = Dyn::Strip(Dyn::Slice(line, 3));
|
||||||
|
(std::ranges::contains(allowDirty, path) ? allowed : counted).push_back(line);
|
||||||
|
}
|
||||||
|
return {counted, allowed};
|
||||||
|
}
|
||||||
|
|
||||||
|
Base ResolveBase(const fs::path& repoPath, std::string_view base, const std::optional<std::string>& configBase, std::string_view rangeFrom) {
|
||||||
|
const std::string repo = repoPath.string();
|
||||||
|
Base result;
|
||||||
|
const GitRun up = Git(repo, {"rev-parse", "--verify", "-q", "@{upstream}"});
|
||||||
|
if (up.code == 0) result.upstream = Dyn::Strip(up.out);
|
||||||
|
auto mergeBase = [&]() -> std::optional<std::string> {
|
||||||
|
const GitRun run = Git(repo, {"merge-base", "HEAD", *configBase});
|
||||||
|
if (run.code != 0) {
|
||||||
|
result.failure = std::format("config base {} does not resolve: {}", Dyn::Repr(*configBase), Dyn::Strip(run.err));
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return Dyn::Strip(run.out);
|
||||||
|
};
|
||||||
|
const bool haveConfigBase = configBase && !configBase->empty();
|
||||||
|
std::string chosen;
|
||||||
|
if (!base.empty()) {
|
||||||
|
chosen = base;
|
||||||
|
result.how = "--base";
|
||||||
|
} else if (rangeFrom == "base" && haveConfigBase) {
|
||||||
|
const std::optional<std::string> found = mergeBase();
|
||||||
|
if (!found) return result;
|
||||||
|
chosen = *found;
|
||||||
|
result.how = std::format("merge-base HEAD {} (config rangeFrom base)", *configBase);
|
||||||
|
} else if (result.upstream) {
|
||||||
|
chosen = *result.upstream;
|
||||||
|
result.how = "@{upstream}";
|
||||||
|
} else if (haveConfigBase) {
|
||||||
|
const std::optional<std::string> found = mergeBase();
|
||||||
|
if (!found) return result;
|
||||||
|
chosen = *found;
|
||||||
|
result.how = std::format("merge-base HEAD {} (config base)", *configBase);
|
||||||
|
} else {
|
||||||
|
result.failure = "no upstream branch configured, no --base given and no base in .review-gates/config.json";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const GitRun resolved = Git(repo, {"rev-parse", "--verify", "-q", std::format("{}^{{commit}}", chosen)});
|
||||||
|
if (resolved.code != 0) {
|
||||||
|
result.how.clear();
|
||||||
|
result.failure = std::format("base {} does not resolve: {}", Dyn::Repr(chosen), Dyn::Strip(resolved.err));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.sha = Dyn::Strip(resolved.out);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json Run(const fs::path& repoPath, const fs::path& outPath, const Options& options) {
|
||||||
|
const std::string cwd = options.cwd.empty() ? fs::current_path().string() : options.cwd.string();
|
||||||
|
const std::string repo = Dyn::AbsPath(repoPath.string());
|
||||||
|
const std::string out = Dyn::AbsPath(outPath.string());
|
||||||
|
Json report = {
|
||||||
|
{"ok", false}, {"repo", repo}, {"out", out}, {"date", options.date.empty() ? Platform::TodayIso() : options.date},
|
||||||
|
{"failures", Json::array()}, {"warnings", Json::array()},
|
||||||
|
};
|
||||||
|
auto fail = [&](std::string message) { report["failures"].push_back(std::move(message)); };
|
||||||
|
auto warn = [&](std::string message) { report["warnings"].push_back(std::move(message)); };
|
||||||
|
auto failed = [&] { return !report["failures"].empty(); };
|
||||||
|
if (!Artifact::IsToplevel(repo)) {
|
||||||
|
fail(std::format("{} is not the toplevel of a git checkout", repo));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
std::vector<std::string> authors = options.authors;
|
||||||
|
if (authors.empty()) authors = Config::Authors({{"authors", nullptr}}, repo);
|
||||||
|
if (authors.empty()) {
|
||||||
|
fail("no author identity: set `authors` in .review-gates/config.json or git config user.email");
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
const Base base = ResolveBase(repo, options.base, options.configBase, options.rangeFrom);
|
||||||
|
if (!base.failure.empty()) {
|
||||||
|
fail(base.failure);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
const std::string head = Artifact::TreeHead(repo);
|
||||||
|
Json authorList = Json::array();
|
||||||
|
for (const std::string& author : authors) authorList.push_back(author);
|
||||||
|
report["base"] = base.sha;
|
||||||
|
report["baseFrom"] = base.how;
|
||||||
|
report["upstream"] = base.upstream ? Json(*base.upstream) : Json();
|
||||||
|
report["head"] = head;
|
||||||
|
report["authors"] = authorList;
|
||||||
|
report["aiTrailers"] = options.aiTrailers;
|
||||||
|
if (Git(repo, {"merge-base", "--is-ancestor", base.sha, "HEAD"}).code != 0) {
|
||||||
|
fail(std::format("base {} is not an ancestor of HEAD {}", Head12(base.sha), Head12(head)));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
if (base.upstream) {
|
||||||
|
const GitRun behind = Git(repo, {"rev-list", "--count", std::format("HEAD..{}", *base.upstream)});
|
||||||
|
const std::string count = Dyn::Strip(behind.out);
|
||||||
|
if (behind.code == 0 && (count.empty() ? 0 : Dyn::ParseInt(count).value_or(0)) != 0) {
|
||||||
|
fail(std::format("upstream has {} commit(s) HEAD lacks - the push would not be a fast-forward; fetch and rebase first", count));
|
||||||
|
}
|
||||||
|
if (base.sha != *base.upstream && base.how != "@{upstream}") {
|
||||||
|
warn(std::format("base {} ({}) is not the upstream tip {}: the artifact is the whole range, not only the push delta", Head12(base.sha), base.how, Head12(*base.upstream)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn(std::format("no upstream branch: fast-forward not checked{}", base.how.empty() ? std::string() : std::format("; the base is {}", base.how)));
|
||||||
|
}
|
||||||
|
if (base.sha == head) fail("nothing to freeze: base == HEAD");
|
||||||
|
if (failed()) return report;
|
||||||
|
const GitRun merges = Git(repo, {"rev-list", "--merges", std::format("{}..HEAD", base.sha)});
|
||||||
|
if (!Dyn::Strip(merges.out).empty()) {
|
||||||
|
std::vector<std::string> shas;
|
||||||
|
for (const std::string& sha : Dyn::Split(merges.out)) shas.push_back(Head12(sha));
|
||||||
|
fail(std::format("merge commit(s) in the range (git format-patch silently drops them): {}", Dyn::Join(shas, ", ")));
|
||||||
|
}
|
||||||
|
const std::vector<Commit> commits = CommitsIn(repo, base.sha);
|
||||||
|
Json commitList = Json::array();
|
||||||
|
for (const Commit& c : commits) {
|
||||||
|
commitList.push_back({{"sha", c.sha}, {"subject", c.subject}, {"author", c.author}, {"committer", c.committer}, {"sig", c.sig}});
|
||||||
|
}
|
||||||
|
report["commits"] = commitList;
|
||||||
|
const std::string authorText = Dyn::Join(authors, ", ");
|
||||||
|
for (const Commit& c : commits) {
|
||||||
|
const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50)));
|
||||||
|
if (!std::ranges::contains(authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText));
|
||||||
|
if (!std::ranges::contains(authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText));
|
||||||
|
if (const std::optional<std::string> trailer = AiTrailer(c.body)) {
|
||||||
|
const bool forbid = options.aiTrailers == "forbid";
|
||||||
|
const std::string message = std::format("{}: AI attribution trailer {}{}", who, Dyn::Repr(Dyn::Strip(*trailer)),
|
||||||
|
forbid ? " - this repository carries none (config aiTrailers: forbid)" : " (config aiTrailers: allow)");
|
||||||
|
forbid ? fail(message) : warn(message);
|
||||||
|
}
|
||||||
|
if (c.sig != "G" && c.sig != "U") {
|
||||||
|
const std::string message = std::format("{}: not GPG-signed (%G? = {})", who, c.sig);
|
||||||
|
options.requireSigned ? fail(message) : warn(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto [counted, allowed] = DirtyPaths(repo, options.allowDirty);
|
||||||
|
if (!counted.empty()) {
|
||||||
|
std::vector<std::string> shown;
|
||||||
|
for (std::size_t i = 0; i < counted.size() && i < 5; ++i) shown.push_back(Dyn::Slice(counted[i], 3));
|
||||||
|
fail(std::format("uncommitted tracked changes in the worktree - commit or stash them; the lenses read the checkout: {}", Dyn::Join(shown, ", ")));
|
||||||
|
}
|
||||||
|
if (!allowed.empty()) {
|
||||||
|
std::vector<std::string> shown;
|
||||||
|
for (const std::string& line : allowed) shown.push_back(Dyn::Slice(line, 3));
|
||||||
|
warn(std::format("tracked changes the config allows (allowDirty - the local stack rewrites them): {}", Dyn::Join(shown, ", ")));
|
||||||
|
}
|
||||||
|
const GitRun all = Git(repo, {"status", "--porcelain", "--untracked-files=all"});
|
||||||
|
std::vector<std::string> untracked;
|
||||||
|
for (const std::string& line : Dyn::SplitLines(all.out)) {
|
||||||
|
if (line.starts_with("??")) untracked.push_back(Dyn::Slice(line, 3));
|
||||||
|
}
|
||||||
|
if (!untracked.empty()) {
|
||||||
|
const std::vector<std::string> shown(untracked.begin(), untracked.begin() + static_cast<std::ptrdiff_t>(std::min<std::size_t>(untracked.size(), 5)));
|
||||||
|
warn(std::format("{} untracked file(s), never part of a commit: {}{}", untracked.size(), Dyn::Join(shown, ", "), untracked.size() > 5 ? " ..." : ""));
|
||||||
|
}
|
||||||
|
if (failed()) return report;
|
||||||
|
fs::create_directories(out);
|
||||||
|
const std::vector<std::string> existing = Artifact::PatchFiles(out);
|
||||||
|
if (!existing.empty()) {
|
||||||
|
if (!options.replace) {
|
||||||
|
fail(std::format("{} already holds {} patch file(s) - pass --replace to re-freeze (gate-state/ is kept)", out, existing.size()));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
for (const std::string& name : existing) fs::remove(fs::path(out) / name);
|
||||||
|
}
|
||||||
|
const GitRun exported = Git(repo, {"format-patch", std::format("--base={}", base.sha), "-o", out, std::format("{}..HEAD", base.sha)});
|
||||||
|
if (exported.code != 0) {
|
||||||
|
fail(std::format("format-patch failed: {}", Dyn::Strip(exported.err)));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> files = Artifact::PatchFiles(out);
|
||||||
|
if (files.size() != commits.size()) {
|
||||||
|
fail(std::format("exported {} patch file(s) for {} commit(s)", files.size(), commits.size()));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
Json fileList = Json::array();
|
||||||
|
for (const std::string& name : files) fileList.push_back(name);
|
||||||
|
report["files"] = fileList;
|
||||||
|
report["hash"] = Artifact::ArtifactHash("commits", out);
|
||||||
|
if (Artifact::PatchHead(out) != head) {
|
||||||
|
fail("the last patch does not name HEAD");
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
const fs::path gateState = fs::path(out) / "gate-state";
|
||||||
|
fs::create_directories(gateState);
|
||||||
|
if (!fs::exists(gateState / "dispositions.json")) std::ofstream(gateState / "dispositions.json", std::ios::binary) << "[]";
|
||||||
|
if (options.worktree) {
|
||||||
|
const std::string name = Dyn::BaseName(Dyn::RStrip(repo, "/"));
|
||||||
|
const std::string hash = report["hash"].get<std::string>();
|
||||||
|
const fs::path worktrees = fs::path(cwd) / Worktrees;
|
||||||
|
const std::string wt = (worktrees / std::format("{}-{}", name, hash.substr(0, 8))).string();
|
||||||
|
// glob.glob(<worktrees>/<name>-*), as glob reads it: readdir order,
|
||||||
|
// and no hidden entry unless the pattern starts with a dot.
|
||||||
|
std::vector<std::string> stale;
|
||||||
|
std::error_code error;
|
||||||
|
if (fs::is_directory(worktrees, error)) {
|
||||||
|
for (const fs::directory_entry& entry : fs::directory_iterator(worktrees, error)) {
|
||||||
|
const std::string entryName = entry.path().filename().string();
|
||||||
|
if (entryName.starts_with('.') && !name.starts_with('.')) continue;
|
||||||
|
if (Dyn::FnMatch(entryName, std::format("{}-*", name)) && entry.path().string() != wt) stale.push_back(entry.path().string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bool present = fs::exists(wt);
|
||||||
|
if (present || !stale.empty()) {
|
||||||
|
std::vector<std::string> superseded;
|
||||||
|
if (present) superseded.push_back(wt);
|
||||||
|
superseded.insert(superseded.end(), stale.begin(), stale.end());
|
||||||
|
if (!options.replace) {
|
||||||
|
std::vector<std::string> shown;
|
||||||
|
for (const std::string& d : superseded) shown.push_back(Dyn::RelPath(d, cwd));
|
||||||
|
fail(std::format("worktree(s) of this repo exist - pass --replace to supersede them: {}", Dyn::Join(shown, ", ")));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
for (const std::string& d : superseded) Git(repo, {"worktree", "remove", "--force", d});
|
||||||
|
}
|
||||||
|
fs::create_directories(fs::path(wt).parent_path());
|
||||||
|
const GitRun added = Git(repo, {"worktree", "add", "--detach", wt, "HEAD"});
|
||||||
|
if (added.code != 0) {
|
||||||
|
fail(std::format("worktree add failed: {}", Dyn::Strip(added.err)));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
report["worktree"] = Dyn::RelPath(wt, cwd);
|
||||||
|
}
|
||||||
|
report["ok"] = true;
|
||||||
|
Json frozen = Json::object();
|
||||||
|
for (auto it = report.begin(); it != report.end(); ++it) {
|
||||||
|
if (it.key() != "ok" && it.key() != "failures") frozen[it.key()] = it.value();
|
||||||
|
}
|
||||||
|
frozen["gitVersion"] = GitVersion();
|
||||||
|
std::ofstream(fs::path(out) / "freeze.json", std::ios::binary) << DumpFile(frozen);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Verify(const fs::path& repoPath, const fs::path& outPath, std::span<const std::string> allowDirty) {
|
||||||
|
const std::string repo = Dyn::AbsPath(repoPath.string());
|
||||||
|
const std::string out = Dyn::AbsPath(outPath.string());
|
||||||
|
const fs::path file = fs::path(out) / "freeze.json";
|
||||||
|
if (!fs::exists(file)) return {std::format("{} missing - not a frozen artifact", file.string())};
|
||||||
|
const Json frozen = Dyn::LoadJsonFile(file);
|
||||||
|
const std::string frozenHash = Dyn::At(frozen, "hash").get<std::string>();
|
||||||
|
const std::string frozenBase = Dyn::At(frozen, "base").get<std::string>();
|
||||||
|
std::vector<std::string> problems;
|
||||||
|
const std::string head = Artifact::TreeHead(repo);
|
||||||
|
const std::string frozenHead = Dyn::At(frozen, "head").get<std::string>();
|
||||||
|
if (head != frozenHead) problems.push_back(std::format("HEAD {} != frozen head {}", Head12(head), Head12(frozenHead)));
|
||||||
|
const auto [counted, allowed] = DirtyPaths(repo, allowDirty);
|
||||||
|
if (!counted.empty()) {
|
||||||
|
std::vector<std::string> shown;
|
||||||
|
for (std::size_t i = 0; i < counted.size() && i < 5; ++i) shown.push_back(Dyn::Slice(counted[i], 3));
|
||||||
|
problems.push_back(std::format("uncommitted tracked changes: {}", Dyn::Join(shown, ", ")));
|
||||||
|
}
|
||||||
|
const std::string current = Artifact::ArtifactHash("commits", out);
|
||||||
|
if (current != frozenHash) problems.push_back(std::format("artifact dir hashes {}, freeze.json says {} (edited after the freeze?)", Head12(current), Head12(frozenHash)));
|
||||||
|
const TemporaryDirectory temporary;
|
||||||
|
const GitRun exported = Git(repo, {"format-patch", std::format("--base={}", frozenBase), "-o", temporary.path.string(), std::format("{}..HEAD", frozenBase)});
|
||||||
|
if (exported.code != 0) {
|
||||||
|
problems.push_back(std::format("re-export failed: {}", Dyn::Strip(exported.err)));
|
||||||
|
} else {
|
||||||
|
const std::string fresh = Artifact::ArtifactHash("commits", temporary.path);
|
||||||
|
if (fresh != frozenHash) {
|
||||||
|
std::string problem = std::format("a fresh export hashes {} != frozen {}", Head12(fresh), Head12(frozenHash));
|
||||||
|
// format-patch signs every patch with its version: the likely
|
||||||
|
// cause when nothing else changed. Where freeze.json has no
|
||||||
|
// gitVersion, the patches still name it.
|
||||||
|
const Json& recorded = Dyn::Get(frozen, "gitVersion");
|
||||||
|
const std::string then = recorded.is_string() && !recorded.get_ref<const std::string&>().empty() ? recorded.get<std::string>() : PatchGitVersion(out);
|
||||||
|
const std::string now = PatchGitVersion(temporary.path);
|
||||||
|
if (!then.empty() && !now.empty() && then != now) {
|
||||||
|
problem += std::format(" - git {} exported the artifact and git {} the fresh copy; format-patch writes its version into every patch, so re-freeze with this git", then, now);
|
||||||
|
}
|
||||||
|
problems.push_back(std::move(problem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RunLine(const Json& report, std::string_view outRel, std::string_view trees) {
|
||||||
|
return std::format("{} run --artifact {} --hash {} --base {} --trees {} --dispositions {} --date {}", Invocation(), outRel, Dyn::Str(Dyn::At(report, "hash")),
|
||||||
|
Dyn::Str(Dyn::At(report, "base")), trees, Dyn::JoinPath(Dyn::JoinPath(outRel, "gate-state"), "dispositions.json"), Dyn::Str(Dyn::At(report, "date")));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t Main(const Arguments& arguments, const Json& config) {
|
||||||
|
const std::string cwd = fs::current_path().string();
|
||||||
|
const std::string repo = Dyn::AbsPath(arguments.repo && !arguments.repo->empty() ? *arguments.repo : cwd);
|
||||||
|
std::optional<std::string> out;
|
||||||
|
if (arguments.out && !arguments.out->empty()) {
|
||||||
|
out = *arguments.out;
|
||||||
|
} else if (arguments.slug && !arguments.slug->empty()) {
|
||||||
|
out = Dyn::JoinPath(PushDir, *arguments.slug);
|
||||||
|
}
|
||||||
|
if (!out) {
|
||||||
|
std::println("FAIL: --slug <name> (the artifact dir .review-gates/push/<name>/) or --out <dir>");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> allowDirty = Strings(Dyn::Get(config, "allowDirty"));
|
||||||
|
if (arguments.verify) {
|
||||||
|
const std::vector<std::string> problems = Verify(repo, *out, allowDirty);
|
||||||
|
const std::string detail = problems.empty() ? std::format("{} is exactly what {} HEAD exports", Dyn::AbsPath(*out), repo) : Dyn::Join(problems, "; ");
|
||||||
|
std::println("{}: {}", problems.empty() ? "MATCH" : "MISMATCH", detail);
|
||||||
|
return problems.empty() ? 0 : 1;
|
||||||
|
}
|
||||||
|
Options options;
|
||||||
|
options.base = arguments.base.value_or("");
|
||||||
|
options.replace = arguments.replace;
|
||||||
|
options.worktree = arguments.worktree;
|
||||||
|
options.requireSigned = arguments.requireSigned || Dyn::Truthy(Dyn::Get(config, "requireSigned"));
|
||||||
|
options.authors = Config::Authors(config, repo);
|
||||||
|
options.aiTrailers = Dyn::Str(Dyn::Get(config, "aiTrailers"));
|
||||||
|
options.allowDirty = allowDirty;
|
||||||
|
const Json& configBase = Dyn::Get(config, "base");
|
||||||
|
if (configBase.is_string()) options.configBase = configBase.get<std::string>();
|
||||||
|
options.rangeFrom = Dyn::Str(Dyn::Get(config, "rangeFrom"));
|
||||||
|
options.date = arguments.date;
|
||||||
|
const Json report = Run(repo, *out, options);
|
||||||
|
for (const Json& warning : report.at("warnings")) std::println("warning: {}", warning.get<std::string>());
|
||||||
|
if (!report.at("ok").get<bool>()) {
|
||||||
|
for (const Json& failure : report.at("failures")) std::println("FAIL: {}", failure.get<std::string>());
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
const std::string rel = Dyn::RelPath(report.at("out").get<std::string>());
|
||||||
|
std::vector<std::string> files;
|
||||||
|
for (const Json& name : report.at("files")) files.push_back(name.get<std::string>());
|
||||||
|
std::println("frozen: {} commit(s) {}..{} (base from {}) -> {}/ ({})", files.size(), Head12(report.at("base").get<std::string>()), Head12(report.at("head").get<std::string>()),
|
||||||
|
report.at("baseFrom").get<std::string>(), rel, Dyn::Join(files, ", "));
|
||||||
|
std::println("hash: {}", report.at("hash").get<std::string>());
|
||||||
|
std::string tree;
|
||||||
|
if (Dyn::Truthy(Dyn::Get(report, "worktree"))) {
|
||||||
|
tree = report.at("worktree").get<std::string>();
|
||||||
|
} else {
|
||||||
|
std::error_code error;
|
||||||
|
tree = fs::weakly_canonical(repo, error) == fs::weakly_canonical(cwd, error) ? std::string(".") : report.at("repo").get<std::string>();
|
||||||
|
}
|
||||||
|
const std::string line = RunLine(report, rel, tree);
|
||||||
|
std::ofstream(fs::path(report.at("out").get<std::string>()) / "run-line.txt", std::ios::binary) << line << "\n";
|
||||||
|
std::println("run (from the repository root, in a detached background shell; also in {}):\n {}", Dyn::JoinPath(rel, "run-line.txt"), line);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
interfaces/Crafter.Review-Freeze.cppm
Normal file
94
interfaces/Crafter.Review-Freeze.cppm
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -22,4 +22,5 @@ export import :PgWire;
|
||||||
export import :Stream;
|
export import :Stream;
|
||||||
export import :Prompt;
|
export import :Prompt;
|
||||||
export import :Config;
|
export import :Config;
|
||||||
|
export import :Freeze;
|
||||||
export import :Driver;
|
export import :Driver;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using namespace Crafter;
|
||||||
namespace {
|
namespace {
|
||||||
// The umbrella, then the partitions in import order: each after the ones
|
// The umbrella, then the partitions in import order: each after the ones
|
||||||
// it imports, which is also the order the module map in AGENTS.md uses.
|
// it imports, which is also the order the module map in AGENTS.md uses.
|
||||||
constexpr std::array<std::string_view, 21> Interfaces = {
|
constexpr std::array<std::string_view, 22> Interfaces = {
|
||||||
"interfaces/Crafter.Review",
|
"interfaces/Crafter.Review",
|
||||||
"interfaces/Crafter.Review-Unicode",
|
"interfaces/Crafter.Review-Unicode",
|
||||||
"interfaces/Crafter.Review-Json",
|
"interfaces/Crafter.Review-Json",
|
||||||
|
|
@ -34,9 +34,10 @@ namespace {
|
||||||
"interfaces/Crafter.Review-Stream",
|
"interfaces/Crafter.Review-Stream",
|
||||||
"interfaces/Crafter.Review-Prompt",
|
"interfaces/Crafter.Review-Prompt",
|
||||||
"interfaces/Crafter.Review-Config",
|
"interfaces/Crafter.Review-Config",
|
||||||
|
"interfaces/Crafter.Review-Freeze",
|
||||||
"interfaces/Crafter.Review-Driver",
|
"interfaces/Crafter.Review-Driver",
|
||||||
};
|
};
|
||||||
constexpr std::array<std::string_view, 18> Implementations = {
|
constexpr std::array<std::string_view, 19> Implementations = {
|
||||||
"implementations/Crafter.Review-Text",
|
"implementations/Crafter.Review-Text",
|
||||||
"implementations/Crafter.Review-Crypto",
|
"implementations/Crafter.Review-Crypto",
|
||||||
"implementations/Crafter.Review-Platform",
|
"implementations/Crafter.Review-Platform",
|
||||||
|
|
@ -54,6 +55,7 @@ namespace {
|
||||||
"implementations/Crafter.Review-Stream",
|
"implementations/Crafter.Review-Stream",
|
||||||
"implementations/Crafter.Review-Prompt",
|
"implementations/Crafter.Review-Prompt",
|
||||||
"implementations/Crafter.Review-Config",
|
"implementations/Crafter.Review-Config",
|
||||||
|
"implementations/Crafter.Review-Freeze",
|
||||||
"implementations/Crafter.Review-Driver",
|
"implementations/Crafter.Review-Driver",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,7 +66,7 @@ namespace {
|
||||||
|
|
||||||
// One per directory under tests/, each an ordinary main() that links the
|
// One per directory under tests/, each an ordinary main() that links the
|
||||||
// library: exit 0 passes, nonzero fails, 77 skips.
|
// library: exit 0 passes, nonzero fails, 77 skips.
|
||||||
constexpr std::array<std::string_view, 12> Tests = {
|
constexpr std::array<std::string_view, 13> Tests = {
|
||||||
"Crypto",
|
"Crypto",
|
||||||
"Text",
|
"Text",
|
||||||
"Platform",
|
"Platform",
|
||||||
|
|
@ -77,6 +79,7 @@ namespace {
|
||||||
"Config",
|
"Config",
|
||||||
"CommitsMode",
|
"CommitsMode",
|
||||||
"CodeKeys",
|
"CodeKeys",
|
||||||
|
"Freeze",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The version the binary reports: CI sets it from the tag, a checkout
|
// The version the binary reports: CI sets it from the tag, a checkout
|
||||||
|
|
|
||||||
176
tests/Freeze/main.cpp
Normal file
176
tests/Freeze/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue