fix(external): fetch and build each clone once per build pass #39

Merged
jorijnvdgraaf merged 4 commits from claude/issue-34 into master 2026-10-31 02:20:57 +00:00
2 changed files with 138 additions and 0 deletions
Showing only changes of commit 0a06faec6e - Show all commits

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 <noreply@anthropic.com>
Jorijn van der Graaf 2026-10-31 02:09:24 +01:00
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB

View file

@ -262,6 +262,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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() });

View file

@ -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<fs::path, 0> interfaces = {};
std::array<fs::path, 1> 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;
}