Crafter.Review/implementations/Crafter.Review-Freeze.cpp

499 lines
27 KiB
C++
Raw Normal View History

// 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::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<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>());
const std::string line = RunLine(report, rel, TreeOf(report, cwd));
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;
}
}