[M8] cargoxx-git dependencies: { git = ..., rev = ... } deps

This commit is contained in:
2026-05-17 18:31:13 +00:00
parent e6c39914b3
commit 09f151ad82
12 changed files with 310 additions and 28 deletions

View File

@@ -498,3 +498,33 @@ All notable changes to cargoxx will be documented in this file.
validation + binary-cache substitution. Phase 1a (install rules)
and Phase 1b (path deps) shipped in this commit; phases 1c, 1d,
and 2 remain to be built.
- M8 cargoxx-git dependencies (`{ git = "<url>", rev = "<40-char>" }`).
The manifest accepts a third dep-table form:
```toml
[dependencies]
mylib = { git = "https://gitea/me/mylib", rev = "abc…" }
```
Branch/tag refs are not yet supported — the rev must be a literal
40-char commit. `manifest::Dependency` carries `git_url` + `git_rev`;
parser branches on `git`, rejects git deps that omit `rev`.
Lockfile schema adds `source_git_url` + `source_git_commit` +
`source_git_sha256` (SRI form, e.g. `sha256-<base64>`). The sha256
pins the fetched source as a fixed-output derivation: `pkgs.fetchgit`
in the consumer's `buildCppPackage` substitutes from cache when the
same `(url, rev, sha256)` triple appears elsewhere.
`cmd_build::resolve_git_dep` reuses the lockfile's prior sha256 on
re-builds; on a fresh dep it calls
`resolver::prefetch_flake_source(git+<url>?rev=<rev>)` which now
returns `{store_path, hash}` (extended via a new
`PrefetchedSource` struct, with `realize_flake_source` kept as a
thin compat wrapper). Verifies the dep's name by reading the
fetched tree's `Cargoxx.toml`. In `--offline` mode, a git dep
without a cached hash errors with a clear "run online first"
message.
`flake.nix`'s `evalDep` branches on `source_kind == "cargoxx-git"`
and feeds `pkgs.fetchgit { url, rev, hash }` into a recursive
`buildCppPackage` call. The fetched source is content-addressed,
so the entire `(fetch → install)` closure is cacheable end-to-end.
Codegen unchanged — the synthesized recipe (find_package +
`<name>::<name>` target) is identical to the path-dep case, just
with a different `linkdb_source` discriminator.

View File

@@ -69,12 +69,19 @@
(builtins.getFlake "github:NixOS/nixpkgs/${rev}")
.legacyPackages.${system};
# cargoxx-path deps recurse into buildCppPackage on the sibling
# source tree; the result joins buildInputs so the consumer's
# find_package(<dep> CONFIG REQUIRED) resolves via CMAKE_PREFIX_PATH.
# cargoxx-source deps recurse into buildCppPackage; the result
# joins buildInputs so the consumer's find_package(<dep> CONFIG
# REQUIRED) resolves via CMAKE_PREFIX_PATH.
evalDep = p:
if (p.source_kind or "") == "cargoxx-path" then
buildCppPackage { src = src + ("/" + p.source_path); }
else if (p.source_kind or "") == "cargoxx-git" then
let depSrc = pkgs.fetchgit {
url = p.source_git_url;
rev = p.source_git_commit;
hash = p.source_git_sha256;
};
in buildCppPackage { src = depSrc; }
else
let rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev
@@ -97,9 +104,9 @@
# For cargoxx-source deps we don't have a nixpkgs rev/attr — the
# vendor.toml entry just needs a name + store_path so cargoxx's
# offline pathway can find the dep's installed prefix.
isPath = (p.source_kind or "") == "cargoxx-path";
attr = if isPath then "" else p.nixpkgs_attr;
rev = if isPath then ""
isCargoxx = (p.source_kind or "") != "";
attr = if isCargoxx then "" else p.nixpkgs_attr;
rev = if isCargoxx then ""
else if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev else lock.nixpkgs_rev;
in ''

View File

@@ -74,7 +74,9 @@ auto query_flake_rev(std::string_view flake_ref) -> std::optional<std::string> {
auto merge_lockfile(const manifest::Manifest& m,
const std::vector<linkdb::Recipe>& recipes,
const std::vector<linkdb::Recipe>& dev_recipes,
const lockfile::Lockfile& prior) -> lockfile::Lockfile {
const lockfile::Lockfile& prior,
const std::map<std::string, std::string>& git_sha256s)
-> lockfile::Lockfile {
auto find_prior = [&](const std::string& name, const std::string& version)
-> std::optional<lockfile::LockfilePackage> {
for (const auto& p : prior.packages) {
@@ -122,9 +124,19 @@ auto merge_lockfile(const manifest::Manifest& m,
}
std::optional<std::string> source_kind;
std::optional<std::string> source_path;
std::optional<std::string> source_git_url;
std::optional<std::string> source_git_commit;
std::optional<std::string> source_git_sha256;
if (dep.source == manifest::DepSource::CargoxxPath) {
source_kind = "cargoxx-path";
source_path = dep.path;
} else if (dep.source == manifest::DepSource::CargoxxGit) {
source_kind = "cargoxx-git";
source_git_url = dep.git_url;
source_git_commit = dep.git_rev;
if (auto it = git_sha256s.find(dep.name); it != git_sha256s.end()) {
source_git_sha256 = it->second;
}
}
lock.packages.push_back(lockfile::LockfilePackage{
.name = dep.name,
@@ -140,6 +152,9 @@ auto merge_lockfile(const manifest::Manifest& m,
.brute_force_includes = rec.brute_force_includes,
.source_kind = std::move(source_kind),
.source_path = std::move(source_path),
.source_git_url = std::move(source_git_url),
.source_git_commit = std::move(source_git_commit),
.source_git_sha256 = std::move(source_git_sha256),
});
};
for (std::size_t i = 0; i < m.dependencies.size(); ++i) {
@@ -288,6 +303,70 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
};
};
// Side channel: (dep name) → SRI hash captured during git resolution,
// consumed by merge_lockfile to persist source_git_sha256.
std::map<std::string, std::string> git_sha256s;
// For { git = "...", rev = "..." } deps: if the prior lockfile already
// records an SRI hash for this (url, commit), reuse it. Otherwise run
// `nix flake prefetch` to fetch + hash the source tree (FOD-compatible),
// then verify the dep's Cargoxx.toml name matches.
auto resolve_git_dep = [&](const manifest::Dependency& dep)
-> util::Result<linkdb::Recipe> {
std::optional<std::string> cached_sha;
for (const auto& p : prior.packages) {
if (p.name == dep.name && p.source_kind == "cargoxx-git" &&
p.source_git_url == dep.git_url &&
p.source_git_commit == dep.git_rev && p.source_git_sha256) {
cached_sha = p.source_git_sha256;
break;
}
}
if (!cached_sha) {
if (offline) {
return std::unexpected(util::Error{
util::ErrorCode::BuildCmakeFailed,
std::format("git dep '{}' has no cached hash and --offline "
"forbids network fetch", dep.name),
"run `cargoxx build` once online to populate Cargoxx.lock",
std::nullopt, std::nullopt,
});
}
auto flake_ref = std::format("git+{}?rev={}", *dep.git_url, *dep.git_rev);
auto prefetched = resolver::prefetch_flake_source(flake_ref);
if (!prefetched) {
return std::unexpected(prefetched.error());
}
// Verify name matches by reading the fetched tree's Cargoxx.toml.
auto dep_manifest = manifest::parse(
std::filesystem::path{prefetched->store_path} / "Cargoxx.toml");
if (!dep_manifest) {
return std::unexpected(dep_manifest.error());
}
if (dep_manifest->package.name != dep.name) {
return std::unexpected(util::Error{
util::ErrorCode::ManifestInvalidField,
std::format("git dep '{}' points to a project named '{}'",
dep.name, dep_manifest->package.name),
"rename the dep or use a repo whose [package].name matches",
std::nullopt, std::nullopt,
});
}
cached_sha = prefetched->hash;
}
git_sha256s[dep.name] = *cached_sha;
return linkdb::Recipe{
.nixpkgs_attr = "",
.find_package = std::format("{} CONFIG REQUIRED", dep.name),
.targets = {std::format("{}::{}", dep.name, dep.name)},
.source = "cargoxx-git",
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
};
};
auto resolve_list = [&](const std::vector<manifest::Dependency>& deps)
-> util::Result<std::vector<linkdb::Recipe>> {
std::vector<linkdb::Recipe> out;
@@ -301,6 +380,14 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
out.push_back(std::move(*r));
continue;
}
if (dep.source == manifest::DepSource::CargoxxGit) {
auto r = resolve_git_dep(dep);
if (!r) {
return std::unexpected(r.error());
}
out.push_back(std::move(*r));
continue;
}
if (auto cached = recipe_from_lock(dep.name, dep.version_spec); cached) {
out.push_back(std::move(*cached));
continue;
@@ -329,7 +416,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
if (!dev_recipes) {
return std::unexpected(dev_recipes.error());
}
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior);
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior, git_sha256s);
std::optional<codegen::VendorIndex> vendor_index;
if (offline) {

View File

@@ -112,6 +112,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto v = tbl["source_path"].value<std::string>()) {
pkg.source_path = *v;
}
if (auto v = tbl["source_git_url"].value<std::string>()) {
pkg.source_git_url = *v;
}
if (auto v = tbl["source_git_commit"].value<std::string>()) {
pkg.source_git_commit = *v;
}
if (auto v = tbl["source_git_sha256"].value<std::string>()) {
pkg.source_git_sha256 = *v;
}
return pkg;
}
@@ -227,6 +236,15 @@ auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Res
if (p.source_path) {
tbl.insert_or_assign("source_path", *p.source_path);
}
if (p.source_git_url) {
tbl.insert_or_assign("source_git_url", *p.source_git_url);
}
if (p.source_git_commit) {
tbl.insert_or_assign("source_git_commit", *p.source_git_commit);
}
if (p.source_git_sha256) {
tbl.insert_or_assign("source_git_sha256", *p.source_git_sha256);
}
packages.push_back(std::move(tbl));
}
root.insert_or_assign("package", std::move(packages));

View File

@@ -18,10 +18,15 @@ struct LockfilePackage {
std::optional<std::string> pkg_config_module;
std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes;
// For cargoxx-source deps (not nixpkgs/linkdb-resolved). v1 supports
// "cargoxx-path"; "cargoxx-git" / "cargoxx-registry" land in 1c/1d.
// For cargoxx-source deps (not nixpkgs/linkdb-resolved).
// "cargoxx-path" → source_path only
// "cargoxx-git" → source_git_url + source_git_commit + source_git_sha256
// "cargoxx-registry" → (1d)
std::optional<std::string> source_kind;
std::optional<std::string> source_path;
std::optional<std::string> source_git_url;
std::optional<std::string> source_git_commit;
std::optional<std::string> source_git_sha256;
bool operator==(const LockfilePackage&) const = default;
};

View File

@@ -8,6 +8,7 @@ export namespace cargoxx::manifest {
enum class DepSource {
Auto, // string form or { version = ... } only → existing resolver chain
CargoxxPath, // { path = "../foo" } → recursive cargoxx build
CargoxxGit, // { git = "...", rev = "..." } → fetch + recursive cargoxx build
};
struct Dependency {
@@ -15,7 +16,9 @@ struct Dependency {
std::string version_spec;
std::vector<std::string> components;
DepSource source = DepSource::Auto;
std::optional<std::string> path; // when source == CargoxxPath
std::optional<std::string> path; // when source == CargoxxPath
std::optional<std::string> git_url; // when source == CargoxxGit
std::optional<std::string> git_rev; // when source == CargoxxGit (40-char)
bool operator==(const Dependency&) const = default;
};

View File

@@ -153,9 +153,7 @@ auto parse_dependency(std::string name, const toml::node& value,
}
if (const auto* tbl = value.as_table()) {
// Path form takes precedence: { path = "../foo" } → cargoxx-source dep.
// Version is optional in the path form (defaulting to "*"); the dep's
// own Cargoxx.toml supplies the real version at resolve time.
// Path form: { path = "../foo" } → cargoxx-source dep.
if (auto path_str = (*tbl)["path"].value<std::string>()) {
dep.source = DepSource::CargoxxPath;
dep.path = *path_str;
@@ -167,13 +165,37 @@ auto parse_dependency(std::string name, const toml::node& value,
return dep;
}
// Git form: { git = "...", rev = "<40-char>" } → cargoxx-source dep
// fetched at the pinned commit. Branch/tag resolution is not yet
// supported; require a literal commit.
if (auto git_url = (*tbl)["git"].value<std::string>()) {
auto rev = (*tbl)["rev"].value<std::string>();
if (!rev) {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("git dependency '{}' must specify a 'rev' (40-char commit)",
dep.name),
path, source_pos(value)));
}
dep.source = DepSource::CargoxxGit;
dep.git_url = *git_url;
dep.git_rev = *rev;
if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v;
} else {
dep.version_spec = "*";
}
return dep;
}
if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v;
} else {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("dependency '{}' table must have a 'version' or 'path' string",
dep.name),
std::format(
"dependency '{}' table must have one of: 'version', 'path', 'git'",
dep.name),
path, source_pos(value)));
}
if (const auto* comps = (*tbl)["components"].as_array()) {

View File

@@ -54,6 +54,12 @@ auto build_table(const Manifest& m) -> toml::table {
dep_tbl.insert_or_assign("path", *dep.path);
dep_tbl.is_inline(true);
out.insert_or_assign(dep.name, std::move(dep_tbl));
} else if (dep.source == DepSource::CargoxxGit) {
toml::table dep_tbl;
dep_tbl.insert_or_assign("git", *dep.git_url);
dep_tbl.insert_or_assign("rev", *dep.git_rev);
dep_tbl.is_inline(true);
out.insert_or_assign(dep.name, std::move(dep_tbl));
} else if (dep.components.empty()) {
out.insert_or_assign(dep.name, dep.version_spec);
} else {

View File

@@ -207,12 +207,31 @@ auto realize_path_at_rev(const std::string& rev, const std::string& attr)
return path;
}
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string> {
namespace {
auto extract_json_string(std::string_view body, std::string_view key)
-> std::optional<std::string> {
auto needle = std::format("\"{}\":\"", key);
auto pos = body.find(needle);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += needle.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::nullopt;
}
return std::string{body.substr(pos, end - pos)};
}
} // namespace
auto prefetch_flake_source(const std::string& flake_ref)
-> util::Result<PrefetchedSource> {
if (flake_ref.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
"realize_flake_source: flake_ref is empty"));
"prefetch_flake_source: flake_ref is empty"));
}
std::vector<std::string> args{
"--extra-experimental-features", "nix-command flakes",
@@ -234,23 +253,33 @@ auto realize_flake_source(const std::string& flake_ref)
std::format("nix flake prefetch failed (exit {}): {}",
r->exit_code, r->stderr_text)));
}
std::string_view body = r->stdout_text;
constexpr std::string_view key = "\"storePath\":\"";
auto pos = body.find(key);
if (pos == std::string_view::npos) {
auto store_path = extract_json_string(r->stdout_text, "storePath");
auto hash = extract_json_string(r->stdout_text, "hash");
if (!store_path) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch emitted no storePath for '{}'",
flake_ref)));
}
pos += key.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
if (!hash) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
"nix flake prefetch JSON malformed"));
std::format("nix flake prefetch emitted no hash for '{}'",
flake_ref)));
}
return std::string{body.substr(pos, end - pos)};
return PrefetchedSource{
.store_path = std::move(*store_path),
.hash = std::move(*hash),
};
}
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string> {
auto r = prefetch_flake_source(flake_ref);
if (!r) {
return std::unexpected(r.error());
}
return std::move(r->store_path);
}
} // namespace cargoxx::resolver

View File

@@ -59,6 +59,17 @@ auto realize_path_at_rev(const std::string& rev, const std::string& attr)
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string>;
struct PrefetchedSource {
std::string store_path;
std::string hash; // SRI form, e.g. "sha256-<base64>"
};
// Same as realize_flake_source but also returns the SRI hash so the
// caller can persist it in a lockfile and feed it to `pkgs.fetchgit`
// as a fixed-output derivation pin. Used by cargoxx-git deps.
auto prefetch_flake_source(const std::string& flake_ref)
-> util::Result<PrefetchedSource>;
// One CMake config-file's IMPORTED targets together with the find_package
// expression derived from its filename stem.
struct NixCmakeCandidate {

View File

@@ -142,6 +142,33 @@ TEST_CASE("write round-trips cargoxx-path source fields", "[lockfile]") {
REQUIRE(round_trip(l) == l);
}
TEST_CASE("write round-trips cargoxx-git source fields", "[lockfile]") {
Lockfile l{
.version = 1,
.packages = {
LockfilePackage{
.name = "mylib",
.version = "*",
.dependencies = {},
.nixpkgs_attr = std::nullopt,
.nixpkgs_rev = std::nullopt,
.linkdb_source = "cargoxx-git",
.find_package = "mylib CONFIG REQUIRED",
.targets = {"mylib::mylib"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
.source_kind = "cargoxx-git",
.source_path = std::nullopt,
.source_git_url = "https://gitea.example/me/mylib",
.source_git_commit = "0123456789012345678901234567890123456789",
.source_git_sha256 = "sha256-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123=",
},
},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") {
Lockfile l{
.version = 1,

View File

@@ -336,3 +336,40 @@ mylib = { components = ["a"] }
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}
TEST_CASE("parse recognizes { git = \"...\", rev = \"...\" } as a cargoxx git dep",
"[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { git = "https://gitea.example/me/mylib", rev = "0123456789012345678901234567890123456789" }
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->dependencies.size() == 1);
const auto& dep = r->dependencies[0];
REQUIRE(dep.name == "mylib");
REQUIRE(dep.source == cargoxx::manifest::DepSource::CargoxxGit);
REQUIRE(dep.git_url == "https://gitea.example/me/mylib");
REQUIRE(dep.git_rev == "0123456789012345678901234567890123456789");
REQUIRE(dep.version_spec == "*");
}
TEST_CASE("parse rejects git dep without rev", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { git = "https://gitea.example/me/mylib" }
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}