// 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; } const std::optional>& authors = options.authors; 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; if (authors) { 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; if (!authors) warn("no authors in .review-gates/config.json: author and committer not checked"); const std::string authorText = authors ? Dyn::Join(*authors, ", ") : std::string(); for (const Commit& c : commits) { const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50))); if (authors && !std::ranges::contains(*authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText)); if (authors && !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::string TreeOf(const Json& record, const fs::path& cwd) { const Json& worktree = Dyn::Get(record, "worktree"); if (Dyn::Truthy(worktree)) return Dyn::Str(worktree); const Json& repo = Dyn::Get(record, "repo"); if (!Dyn::Truthy(repo)) return "."; std::error_code error; return fs::weakly_canonical(fs::path(Dyn::Str(repo)), error) == fs::weakly_canonical(cwd, error) ? std::string(".") : Dyn::Str(repo); } Reviewed ReviewedBy(const fs::path& artifact, const fs::path& cwd) { const fs::path file = artifact / "freeze.json"; std::error_code error; if (!fs::exists(file, error)) return {cwd, "."}; Json record; try { record = Dyn::LoadJsonFile(file); } catch (const std::exception& e) { throw std::runtime_error(std::format("{}: not a freeze record ({})", file.string(), e.what())); } if (!record.is_object()) throw std::runtime_error(std::format("{}: not a freeze record", file.string())); const Json& repo = Dyn::Get(record, "repo"); return {Dyn::Truthy(repo) ? fs::path(Dyn::AbsPath(Dyn::Str(repo))) : cwd, TreeOf(record, cwd)}; } 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); 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()); const std::string line = RunLine(report, rel, TreeOf(report, cwd)); 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; } }