From 0a06faec6e305eef6d4454b291198daf29ee1a1d Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 02:09:24 +0100 Subject: [PATCH] test(external): tests that share a dependency fetch it once a run Eight tests that include a header from the same tracked branch of a local repository go through RunTests at once, and again after the branch has moved. GIT_TRACE logs every git process, and each fetch of the repository, clone or pull, runs one `git upload-pack` in it, so the trace counts the fetches: one per run rather than one per test, and the second run still pulls and brings the clone to the new commit. On the commit before the fix both runs fetch eight times. The tests themselves pass there, because the lock from 653dd0b already keeps the pulls apart. Going through RunTests, rather than Build() on threads of the test's own, also holds RunTests to building its tests with one depResults. Co-Authored-By: Claude Opus 5.5 --- project.cpp | 1 + tests/SharedExternalFetch/main.cpp | 137 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 tests/SharedExternalFetch/main.cpp 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; +}