diff --git a/project.cpp b/project.cpp index 8ba59fe..af83a8d 100644 --- a/project.cpp +++ b/project.cpp @@ -262,6 +262,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("LinkFlagsChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ExternalArtifactTime").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ConcurrentExternalFetch").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("SharedExternalFetch").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/SharedExternalFetch/main.cpp b/tests/SharedExternalFetch/main.cpp new file mode 100644 index 0000000..17cf923 --- /dev/null +++ b/tests/SharedExternalFetch/main.cpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// A project whose tests each copy its externalDependencies gives every test +// configuration the same unpinned dependency, and `crafter-build test` +// builds those configurations side by side. Each of them pulled the shared +// clone: at first all at once, which broke the clone's FETCH_HEAD and failed +// every test (issue #34), and then, under the clone's lock, one after +// another, a network round trip per test. The configurations of one build +// pass now share a single fetch and build of the dependency, and the next +// pass fetches it again. +// +// Eight tests that include a header from the same tracked branch of a local +// repository run through RunTests at once, and again after the branch has +// moved. GIT_TRACE logs every git process, and every fetch of the +// repository, clone or pull, runs `git upload-pack` in it once. Everything +// is local; XDG_CACHE_HOME keeps the clone out of the real cache. + +extern "C" int setenv(const char* name, const char* value, int overwrite); + +namespace { + std::int32_t failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } + + void Git(const fs::path& dir, std::string_view args) { + const std::string command = std::format("git -C {} {}", ShellQuote(dir.string()), args); + auto result = RunCommandWithTimeout(command, std::chrono::seconds(60)); + if (result.exitCode != 0) throw std::runtime_error(std::format("{} failed: {}", command, result.output)); + } + + void Write(const fs::path& file, std::string_view text) { + std::ofstream(file, std::ios::binary | std::ios::trunc) << text; + } + + void Commit(const fs::path& repo, std::uint32_t round) { + Write(repo / "round.h", std::format("#define ROUND {}\n", round)); + Git(repo, "add round.h"); + Git(repo, std::format("commit -q -m round-{}", round)); + } + + std::uint32_t Fetches(const fs::path& trace) { + std::ifstream in(trace); + std::uint32_t count = 0; + for (std::string line; std::getline(in, line);) { + if (line.contains("trace: built-in: git upload-pack")) ++count; + } + return count; + } + + // The first test that did not pass, for the message. + std::string FirstFailure(const TestSummary& summary) { + for (const TestResult& result : summary.results) { + if (result.outcome != TestOutcome::Pass) return std::format(", the first: {}: {}", result.name, result.output); + } + return ""; + } +} + +int main() { + const fs::path scratch = fs::temp_directory_path() / "crafter-build-shared-external-fetch"; + fs::remove_all(scratch); + fs::create_directories(scratch); + setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1); + setenv("GIT_CONFIG_NOSYSTEM", "1", 1); + setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1); + for (const char* name : { "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME" }) setenv(name, "crafter-build test", 1); + for (const char* name : { "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL" }) setenv(name, "test@example.invalid", 1); + const fs::path trace = scratch / "git-trace.log"; + setenv("GIT_TRACE", trace.c_str(), 1); + + const fs::path repo = scratch / "shared"; + fs::create_directories(repo); + Git(repo, "init -q -b main"); + Commit(repo, 1); + + ExternalDependency shared; + shared.name = "shared"; + shared.source = { .url = std::format("file://{}", repo.string()), .branch = "main" }; + shared.includeDirs = { "" }; + + const fs::path project = scratch / "project"; + fs::create_directories(project); + Write(project / "main.cpp", "#include \"round.h\"\n\nint main() { return ROUND > 0 ? 0 : 1; }\n"); + + constexpr std::int32_t tests = 8; + Configuration cfg; + cfg.path = project; + cfg.name = "project"; + cfg.target = HostTarget(); + for (std::int32_t i = 0; i < tests; ++i) { + Test& test = cfg.tests.emplace_back(); + test.config.path = project; + test.config.name = std::format("t{}", i); + test.config.outputName = test.config.name; + test.config.target = HostTarget(); + test.config.type = ConfigurationType::Executable; + test.config.externalDependencies = { shared }; + std::array interfaces = {}; + std::array implementations = { "main" }; + test.config.GetInterfacesAndImplementations(interfaces, implementations); + } + const RunTestsOptions options { .jobs = tests }; + + TestSummary summary = RunTests(cfg, options); + Check(summary.passed == tests, std::format("{} tests that share the dependency build and pass at once ({} passed{})", tests, summary.passed, FirstFailure(summary))); + const std::uint32_t first = Fetches(trace); + Check(first == 1, std::format("and fetch it once between them, not once each ({} fetches)", first)); + + Commit(repo, 2); + summary = RunTests(cfg, options); + Check(summary.passed == tests, std::format("after the branch moved, they pass again ({} passed{})", summary.passed, FirstFailure(summary))); + const std::uint32_t second = Fetches(trace) - first; + Check(second == 1, std::format("and the next run fetches it once more ({} fetches)", second)); + std::ifstream header(ExternalCloneDir(shared, HostTarget()) / "round.h"); + std::string round; + std::getline(header, round); + Check(round == "#define ROUND 2", std::format("which brings the clone to the new commit ({})", round)); + + std::error_code ec; + fs::remove_all(scratch, ec); + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +}