diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c2022..a6002e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 = "", 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-`). 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+?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 + + `::` target) is identical to the path-dep case, just + with a different `linkdb_source` discriminator. diff --git a/flake.nix b/flake.nix index 1986885..4449cdb 100644 --- a/flake.nix +++ b/flake.nix @@ -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( CONFIG REQUIRED) resolves via CMAKE_PREFIX_PATH. + # cargoxx-source deps recurse into buildCppPackage; the result + # joins buildInputs so the consumer's find_package( 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 '' diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp index 8517b2a..924bc0d 100644 --- a/src/cli/cmd_build.cpp +++ b/src/cli/cmd_build.cpp @@ -74,7 +74,9 @@ auto query_flake_rev(std::string_view flake_ref) -> std::optional { auto merge_lockfile(const manifest::Manifest& m, const std::vector& recipes, const std::vector& dev_recipes, - const lockfile::Lockfile& prior) -> lockfile::Lockfile { + const lockfile::Lockfile& prior, + const std::map& git_sha256s) + -> lockfile::Lockfile { auto find_prior = [&](const std::string& name, const std::string& version) -> std::optional { for (const auto& p : prior.packages) { @@ -122,9 +124,19 @@ auto merge_lockfile(const manifest::Manifest& m, } std::optional source_kind; std::optional source_path; + std::optional source_git_url; + std::optional source_git_commit; + std::optional 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 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 { + std::optional 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& deps) -> util::Result> { std::vector 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 vendor_index; if (offline) { diff --git a/src/lockfile/lockfile.cpp b/src/lockfile/lockfile.cpp index 2a4456c..1f60407 100644 --- a/src/lockfile/lockfile.cpp +++ b/src/lockfile/lockfile.cpp @@ -112,6 +112,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path) if (auto v = tbl["source_path"].value()) { pkg.source_path = *v; } + if (auto v = tbl["source_git_url"].value()) { + pkg.source_git_url = *v; + } + if (auto v = tbl["source_git_commit"].value()) { + pkg.source_git_commit = *v; + } + if (auto v = tbl["source_git_sha256"].value()) { + 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)); diff --git a/src/lockfile/lockfile.cppm b/src/lockfile/lockfile.cppm index 1b78f27..2ed7c3a 100644 --- a/src/lockfile/lockfile.cppm +++ b/src/lockfile/lockfile.cppm @@ -18,10 +18,15 @@ struct LockfilePackage { std::optional pkg_config_module; std::vector brute_force_libs; std::vector 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 source_kind; std::optional source_path; + std::optional source_git_url; + std::optional source_git_commit; + std::optional source_git_sha256; bool operator==(const LockfilePackage&) const = default; }; diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm index 1f9f26e..3e0c3c1 100644 --- a/src/manifest/manifest.cppm +++ b/src/manifest/manifest.cppm @@ -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 components; DepSource source = DepSource::Auto; - std::optional path; // when source == CargoxxPath + std::optional path; // when source == CargoxxPath + std::optional git_url; // when source == CargoxxGit + std::optional git_rev; // when source == CargoxxGit (40-char) bool operator==(const Dependency&) const = default; }; diff --git a/src/manifest/parser.cpp b/src/manifest/parser.cpp index 54c567e..63fd717 100644 --- a/src/manifest/parser.cpp +++ b/src/manifest/parser.cpp @@ -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()) { 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()) { + auto rev = (*tbl)["rev"].value(); + 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()) { + dep.version_spec = *v; + } else { + dep.version_spec = "*"; + } + return dep; + } + if (auto v = (*tbl)["version"].value()) { 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()) { diff --git a/src/manifest/writer.cpp b/src/manifest/writer.cpp index e478df7..142b1a9 100644 --- a/src/manifest/writer.cpp +++ b/src/manifest/writer.cpp @@ -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 { diff --git a/src/resolver/nixpkgs_probe.cpp b/src/resolver/nixpkgs_probe.cpp index adfae19..d6ad780 100644 --- a/src/resolver/nixpkgs_probe.cpp +++ b/src/resolver/nixpkgs_probe.cpp @@ -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 { +namespace { + +auto extract_json_string(std::string_view body, std::string_view key) + -> std::optional { + 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 { 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 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 { + auto r = prefetch_flake_source(flake_ref); + if (!r) { + return std::unexpected(r.error()); + } + return std::move(r->store_path); } } // namespace cargoxx::resolver diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index db067ab..dcde8fc 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -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; +struct PrefetchedSource { + std::string store_path; + std::string hash; // SRI form, e.g. "sha256-" +}; + +// 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; + // One CMake config-file's IMPORTED targets together with the find_package // expression derived from its filename stem. struct NixCmakeCandidate { diff --git a/tests/lockfile_round_trip.cpp b/tests/lockfile_round_trip.cpp index fa3cf3f..e200676 100644 --- a/tests/lockfile_round_trip.cpp +++ b/tests/lockfile_round_trip.cpp @@ -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, diff --git a/tests/manifest_parse.cpp b/tests/manifest_parse.cpp index 80bf0f3..e92eea9 100644 --- a/tests/manifest_parse.cpp +++ b/tests/manifest_parse.cpp @@ -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); +}