A repository that takes commits from people it cannot list has no way to freeze a range today: a null `authors` falls back to `git config user.email`, so every foreign author or committer fails. Null, or no key, now means neither is checked; the freeze warns that it did not check them and records `authors` as null. A list is checked as before. An empty list, which no commit could pass, is a config error that points at null. Released as 1.1.0.
498 lines
27 KiB
C++
498 lines
27 KiB
C++
// 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;
|
|
}
|
|
const std::optional<std::vector<std::string>>& 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<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;
|
|
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<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);
|
|
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;
|
|
}
|
|
}
|