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;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue