From 61acd6a72e784e6a84a4b9d176bcc2013ba25dba Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 07:03:48 +0100 Subject: [PATCH] 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 --- implementations/Crafter.Review-Driver.cpp | 194 ++++++++- implementations/Crafter.Review-Freeze.cpp | 482 ++++++++++++++++++++++ interfaces/Crafter.Review-Freeze.cppm | 94 +++++ interfaces/Crafter.Review.cppm | 1 + project.cpp | 9 +- tests/Freeze/main.cpp | 176 ++++++++ 6 files changed, 943 insertions(+), 13 deletions(-) create mode 100644 implementations/Crafter.Review-Freeze.cpp create mode 100644 interfaces/Crafter.Review-Freeze.cppm create mode 100644 tests/Freeze/main.cpp diff --git a/implementations/Crafter.Review-Driver.cpp b/implementations/Crafter.Review-Driver.cpp index 89c2c48..4b1ff5b 100644 --- a/implementations/Crafter.Review-Driver.cpp +++ b/implementations/Crafter.Review-Driver.cpp @@ -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> values; + std::set> flags; + bool help = false; + std::string error; + + std::optional 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 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 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 args, std::span specs) { + Parsed parsed; + std::vector 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 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 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 LoadConfig(const std::optional& 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()); + if (!loaded.problems.empty()) { + Die(std::format("config: {}", Dyn::Join(loaded.problems, "; "))); + return std::nullopt; + } + return std::move(loaded.config); + } + + constexpr std::array FreezeOptions = {{ + {"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"}, + {"--slug", true, "SLUG", "the artifact dir name: .review-gates/push//"}, + {"--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 args, const std::optional& 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 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 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 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)); } } diff --git a/implementations/Crafter.Review-Freeze.cpp b/implementations/Crafter.Review-Freeze.cpp new file mode 100644 index 0000000..8cfbd14 --- /dev/null +++ b/implementations/Crafter.Review-Freeze.cpp @@ -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 ..., both outputs captured as text: UTF-8 with + // universal newlines. + GitRun Git(const std::string& repo, std::vector args) { + std::vector 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(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 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 Strings(const Json& list) { + std::vector 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 files = Artifact::PatchFiles(out); + if (files.empty()) return ""; + const std::optional bytes = Platform::ReadFile(out / files.back()); + if (!bytes) return ""; + const std::vector 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 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 out; + for (const std::string& record : Dyn::Split(log.out, "\x1e")) { + if (Dyn::Strip(record).empty()) continue; + const std::vector 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> DirtyPaths(const fs::path& repo, std::span allowDirty) { + const GitRun status = Git(repo.string(), {"status", "--porcelain", "--untracked-files=no"}); + std::vector counted; + std::vector 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& 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 { + 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 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 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 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 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 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 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 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 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 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 shown(untracked.begin(), untracked.begin() + static_cast(std::min(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 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 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(); + const fs::path worktrees = fs::path(cwd) / Worktrees; + const std::string wt = (worktrees / std::format("{}-{}", name, hash.substr(0, 8))).string(); + // glob.glob(/-*), as glob reads it: readdir order, + // and no hidden entry unless the pattern starts with a dot. + std::vector 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 superseded; + if (present) superseded.push_back(wt); + superseded.insert(superseded.end(), stale.begin(), stale.end()); + if (!options.replace) { + std::vector 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 Verify(const fs::path& repoPath, const fs::path& outPath, std::span 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(); + const std::string frozenBase = Dyn::At(frozen, "base").get(); + std::vector problems; + const std::string head = Artifact::TreeHead(repo); + const std::string frozenHead = Dyn::At(frozen, "head").get(); + 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 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().empty() ? recorded.get() : 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 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 (the artifact dir .review-gates/push//) or --out "); + return 3; + } + const std::vector allowDirty = Strings(Dyn::Get(config, "allowDirty")); + if (arguments.verify) { + const std::vector 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(); + 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()); + if (!report.at("ok").get()) { + for (const Json& failure : report.at("failures")) std::println("FAIL: {}", failure.get()); + return 3; + } + const std::string rel = Dyn::RelPath(report.at("out").get()); + std::vector files; + for (const Json& name : report.at("files")) files.push_back(name.get()); + std::println("frozen: {} commit(s) {}..{} (base from {}) -> {}/ ({})", files.size(), Head12(report.at("base").get()), Head12(report.at("head").get()), + report.at("baseFrom").get(), rel, Dyn::Join(files, ", ")); + std::println("hash: {}", report.at("hash").get()); + std::string tree; + if (Dyn::Truthy(Dyn::Get(report, "worktree"))) { + tree = report.at("worktree").get(); + } else { + std::error_code error; + tree = fs::weakly_canonical(repo, error) == fs::weakly_canonical(cwd, error) ? std::string(".") : report.at("repo").get(); + } + const std::string line = RunLine(report, rel, tree); + std::ofstream(fs::path(report.at("out").get()) / "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; + } +} diff --git a/interfaces/Crafter.Review-Freeze.cppm b/interfaces/Crafter.Review-Freeze.cppm new file mode 100644 index 0000000..fb69cb6 --- /dev/null +++ b/interfaces/Crafter.Review-Freeze.cppm @@ -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 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> DirtyPaths(const std::filesystem::path& repo, std::span allowDirty = {}); + + struct Base { + std::string sha; + std::optional 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& configBase, std::string_view rangeFrom); + + struct Options { + std::string base; + bool replace = false; + bool worktree = false; + bool requireSigned = false; + std::vector authors; + std::string aiTrailers = "forbid"; + std::vector allowDirty; + std::optional 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 Verify(const std::filesystem::path& repo, const std::filesystem::path& out, std::span 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 repo; + std::optional slug; + std::optional out; + std::optional 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); +} diff --git a/interfaces/Crafter.Review.cppm b/interfaces/Crafter.Review.cppm index e789048..6116fce 100644 --- a/interfaces/Crafter.Review.cppm +++ b/interfaces/Crafter.Review.cppm @@ -22,4 +22,5 @@ export import :PgWire; export import :Stream; export import :Prompt; export import :Config; +export import :Freeze; export import :Driver; diff --git a/project.cpp b/project.cpp index 049a986..2a55473 100644 --- a/project.cpp +++ b/project.cpp @@ -13,7 +13,7 @@ using namespace Crafter; namespace { // 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. - constexpr std::array Interfaces = { + constexpr std::array Interfaces = { "interfaces/Crafter.Review", "interfaces/Crafter.Review-Unicode", "interfaces/Crafter.Review-Json", @@ -34,9 +34,10 @@ namespace { "interfaces/Crafter.Review-Stream", "interfaces/Crafter.Review-Prompt", "interfaces/Crafter.Review-Config", + "interfaces/Crafter.Review-Freeze", "interfaces/Crafter.Review-Driver", }; - constexpr std::array Implementations = { + constexpr std::array Implementations = { "implementations/Crafter.Review-Text", "implementations/Crafter.Review-Crypto", "implementations/Crafter.Review-Platform", @@ -54,6 +55,7 @@ namespace { "implementations/Crafter.Review-Stream", "implementations/Crafter.Review-Prompt", "implementations/Crafter.Review-Config", + "implementations/Crafter.Review-Freeze", "implementations/Crafter.Review-Driver", }; @@ -64,7 +66,7 @@ namespace { // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. - constexpr std::array Tests = { + constexpr std::array Tests = { "Crypto", "Text", "Platform", @@ -77,6 +79,7 @@ namespace { "Config", "CommitsMode", "CodeKeys", + "Freeze", }; // The version the binary reports: CI sets it from the tag, a checkout diff --git a/tests/Freeze/main.cpp b/tests/Freeze/main.cpp new file mode 100644 index 0000000..d2f8dd5 --- /dev/null +++ b/tests/Freeze/main.cpp @@ -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 +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 parts; + for (const Json& item : list) parts.push_back(item.get()); + 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().contains(part); }); + } + + bool AnyContains(const std::vector& 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 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 args) { + std::vector 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 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 ", "three\n"); + commit("foreign", "four\n", "a.txt", "Cat Bot "); + 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() && 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() && 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() && 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() + && 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()); + Check("re-freeze without --replace refused", !freeze({.base = base}).at("ok").get()); + const Json r0 = freeze({.replace = true}); + Check("no --base, no upstream, no config base -> a failure that says so", !r0.at("ok").get() && r0.at("failures").at(0).get().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() && r1.at("base") == base && r1.at("baseFrom").get().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() && ru.at("baseFrom") == "@{upstream}" && ru.at("files").size() == 1 && rb.at("ok").get() + && rb.at("baseFrom").get().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())) + && 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() && r2.at("worktree").get().starts_with(std::format("{}/r-", Freeze::Worktrees)) + && Artifact::TreeHead(wt) == clean && Artifact::IsToplevel(wt), Joined(r2.at("failures"))); + std::vector 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 allowA = {"a.txt"}; + Check("dirty tree: freeze refuses, verify mismatches", !freeze({.base = base, .replace = true}).at("ok").get() && !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() && 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() && AnyContains(r3.at("failures"), "merge commit")); + g({"worktree", "remove", "--force", wt.string()}); + fs::remove_all(td); + return Failures ? 1 : 0; +}