Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,17 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.xlings
key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }}
key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }}
restore-keys: |
xlings-${{ runner.os }}-release-xl0448-
xlings-${{ runner.os }}-release-xl0460-

- name: Bootstrap mcpp via xlings
env:
XLINGS_NON_INTERACTIVE: '1'
# Pin xlings to a known-good version. The upstream install
# script always grabs `latest` (no version override), so we
# download + self-install manually to avoid broken releases.
XLINGS_VERSION: '0.4.58'
XLINGS_VERSION: '0.4.60'
run: |
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
Expand Down Expand Up @@ -281,7 +281,7 @@ jobs:
- name: Bootstrap mcpp via xlings (latest 0.4.58)
env:
XLINGS_NON_INTERACTIVE: '1'
XLINGS_VERSION: '0.4.58'
XLINGS_VERSION: '0.4.60'
run: |
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
curl -fsSL -o "/tmp/${tarball}" \
Expand Down Expand Up @@ -410,14 +410,14 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.xlings
key: xlings-macos15-release-xl0448-${{ hashFiles('.xlings.json') }}
key: xlings-macos15-release-xl0460-${{ hashFiles('.xlings.json') }}
restore-keys: |
xlings-macos15-release-xl0448-
xlings-macos15-release-xl0460-

- name: Bootstrap mcpp via xlings
env:
XLINGS_NON_INTERACTIVE: '1'
XLINGS_VERSION: '0.4.58'
XLINGS_VERSION: '0.4.60'
run: |
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
WORK=$(mktemp -d)
Expand Down Expand Up @@ -591,15 +591,15 @@ jobs:
uses: actions/cache@v4
with:
path: ~\.xlings
key: xlings-${{ runner.os }}-release-xl0448-${{ hashFiles('.xlings.json') }}
key: xlings-${{ runner.os }}-release-xl0460-${{ hashFiles('.xlings.json') }}
restore-keys: |
xlings-${{ runner.os }}-release-xl0448-
xlings-${{ runner.os }}-release-xl0460-

- name: Bootstrap mcpp via xlings
shell: bash
env:
XLINGS_NON_INTERACTIVE: '1'
XLINGS_VERSION: '0.4.58'
XLINGS_VERSION: '0.4.60'
run: |
WORK=$(mktemp -d)
zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip"
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [0.0.65] — 2026-06-25

### 修复

- **`mcpp add gtest` + `mcpp build` 报 `duplicate symbol: main` / `LNK2005`**(#168):
gtest 作为**常规依赖**时,其 `gtest_main.cc`(自带 main)被链进应用,与应用自身的
main 冲突。修复采用**通用的「feature 门控源」机制**:依赖描述符可声明
`[mcpp].features.<名>.sources`,被某 feature 列出的源**默认不编译/链接**,仅在该
feature 被请求(`dep = { version="…", features=["…"] }`)时纳入。gtest 描述符把
`gtest_main.cc` 归入 `main` feature → **默认只链框架,不再撞 main**;需要 gtest 提供
main 时 `gtest = { version="1.15.2", features=["main"] }` 显式开启。
门控仅作用于 `mcpp build`;`mcpp test` 保持既有的 dev 依赖 main 检测(0.0.64)不变。
详见 `.agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md`。

### 新增

- **`mcpp add --dev <pkg>`**:把依赖写入 `[dev-dependencies]`(测试专属,如 gtest;
由 `mcpp test` 消费,不链进 `mcpp build` 的应用)。

### 测试

- 单元 `SynthesizeFromXpkgLua.FeatureGatedSources`(描述符 feature 门控源解析);
e2e `79_gtest_regular_dep_feature_main.sh`(#168 哨兵 + `features=["main"]` opt-in +
`add --dev`)。

### CI

- release workflow 默认 xlings 版本 `0.4.58` → **`0.4.60`**(缓存键同步更新)。

## [0.0.64] — 2026-06-25

### 修复
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "0.0.64"
version = "0.0.65"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
38 changes: 35 additions & 3 deletions src/build/prepare.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -2113,13 +2113,44 @@ prepare_build(bool print_fingerprint,
};
auto apply = [&](mcpp::modgraph::PackageRoot& pkg,
const std::vector<std::string>& requested) {
for (auto& f : activate(pkg.manifest, requested)) {
auto active = activate(pkg.manifest, requested);
for (auto& f : active) {
auto def = "-DMCPP_FEATURE_" + sanitize(f);
pkg.manifest.buildConfig.cflags.push_back(def);
pkg.manifest.buildConfig.cxxflags.push_back(def);
pkg.privateBuild.cflags.push_back(def);
pkg.privateBuild.cxxflags.push_back(def);
}
// Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"):
// drop EVERY feature-listed glob from the default build, then re-add
// only the ones whose feature is active. Runs even when no feature is
// active, so a gated source is excluded by default.
//
// ONLY in build mode (!includeDevDeps). `mcpp test` (includeDevDeps)
// keeps the full surface so the dev-dependency track's per-test main
// detection (run_tests / make_plan) still sees gtest_main.cc and
// prunes it per test — the two tracks stay decoupled. Combined with
// the descriptor keeping gtest_main.cc in base `sources` too, this
// means test mode is unaffected.
auto& bc = pkg.manifest.buildConfig;
if (!includeDevDeps && !bc.featureSources.empty()) {
std::set<std::string> gated;
for (auto& [f, globs] : bc.featureSources)
for (auto& g : globs) gated.insert(g);
auto drop = [&](std::vector<std::string>& v) {
std::erase_if(v, [&](const std::string& s) { return gated.contains(s); });
};
drop(bc.sources);
drop(pkg.manifest.modules.sources);
std::set<std::string> activeSet(active.begin(), active.end());
for (auto& [f, globs] : bc.featureSources) {
if (!activeSet.contains(f)) continue;
for (auto& g : globs) {
bc.sources.push_back(g);
pkg.manifest.modules.sources.push_back(g);
}
}
}
};
if (!packages.empty()) {
std::vector<std::string> rootReq;
Expand Down Expand Up @@ -2168,8 +2199,9 @@ prepare_build(bool print_fingerprint,
std::println(stderr, "warning: {}", msg);
}
}
if (!req.empty() || packages[i].manifest.featuresMap.contains("default"))
apply(packages[i], req);
// Always apply: even with no requested/default feature, a dep with
// feature-gated sources must have those sources dropped by default.
apply(packages[i], req);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ int run(int argc, char** argv) {
.subcommand(cl::App("add")
.description("Add a dependency to mcpp.toml")
.arg(cl::Arg("pkg").help("Package spec, e.g. foo@1.0.0").required())
.option(cl::Option("dev").help(
"Add to [dev-dependencies] (test-only, e.g. gtest)"))
.action(wrap_rc(mcpp::pm::commands::cmd_add)))
.subcommand(cl::App("remove")
.description("Remove a dependency from mcpp.toml")
Expand Down
56 changes: 56 additions & 0 deletions src/manifest.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ struct Toolchain {
// (new). Defaults are injected by load() after parse if these are empty.
struct BuildConfig {
std::vector<std::string> sources; // glob patterns
// feature name → extra source globs gated by that feature. A glob listed
// here is EXCLUDED from the default build and only compiled/linked when the
// feature is active for this package (resolved in prepare_build). Lets a
// dependency expose an optional component (e.g. gtest's gtest_main.cc behind
// the "main" feature) without it being linked by default — see
// .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md.
std::map<std::string, std::vector<std::string>> featureSources;
std::vector<std::filesystem::path> includeDirs; // relative to package root
std::map<std::filesystem::path, std::string> generatedFiles; // Form B package-owned support files
bool staticStdlib = true;
Expand Down Expand Up @@ -1951,6 +1958,55 @@ synthesize_from_xpkg_lua(std::string_view luaContent,
}
cur.consume('}');
}
else if (key == "features") {
// `{ ["main"] = { sources = { "*/gtest_main.cc" } }, ... }`
// Registers the feature (so it's a known feature) and, when it
// carries `sources`, records them as feature-gated source globs
// (excluded by default; included only when the feature is active —
// resolved in prepare_build). A feature with no `sources` is still
// registered (empty implied set) so it can be requested/validated.
if (!cur.consume('{')) {
return std::unexpected(ManifestError{
"expected '{' after `features =`", m.sourcePath, 0, 0});
}
cur.skip_ws_and_comments();
while (!cur.eof() && cur.peek() != '}') {
auto fname = cur.read_key();
if (fname.empty()) { cur.skip_ws_and_comments(); break; }
cur.skip_ws_and_comments();
if (!cur.consume('=')) break;
cur.skip_ws_and_comments();
if (!cur.consume('{')) break;
// register the feature (no implied features for now)
m.featuresMap.try_emplace(fname, std::vector<std::string>{});
cur.skip_ws_and_comments();
while (!cur.eof() && cur.peek() != '}') {
auto sub = cur.read_key();
cur.skip_ws_and_comments();
if (!cur.consume('=')) break;
cur.skip_ws_and_comments();
if (sub == "sources") {
if (!cur.consume('{')) break;
cur.skip_ws_and_comments();
while (!cur.eof() && cur.peek() != '}') {
auto s = cur.read_string();
if (!s.empty())
m.buildConfig.featureSources[fname].push_back(std::move(s));
cur.skip_ws_and_comments();
}
cur.consume('}');
} else {
// unknown subfield — skip its value
if (cur.peek() == '{') cur.skip_table();
else (void)cur.read_bareword();
}
cur.skip_ws_and_comments();
}
cur.consume('}');
cur.skip_ws_and_comments();
}
cur.consume('}');
}
else if (key == "deps") {
// `{ ["name"] = "version", ["ns.name"] = "version", ... }`
// The mcpp segment uses the flat / dotted form only — namespaced
Expand Down
10 changes: 7 additions & 3 deletions src/pm/commands.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,15 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) {
// - Default namespace → `[dependencies] ... name = "version"` (no quotes).
// - Other namespace → `[dependencies.<ns>] ... name = "version"`,
// creating the subtable if absent.
// --dev → [dev-dependencies] (test-only deps like gtest; consumed by
// `mcpp test`, never linked into `mcpp build` app binaries).
const bool dev = parsed.is_flag_set("dev");
const std::string table = dev ? "dev-dependencies" : "dependencies";
const bool isDefaultNs = !explicitNamespace
|| ns == mcpp::manifest::kDefaultNamespace;
const std::string section = isDefaultNs
? "[dependencies]"
: std::format("[dependencies.{}]", ns);
? std::format("[{}]", table)
: std::format("[{}.{}]", table, ns);
const std::string key = explicitNamespace ? shortName : nameSpec;
auto pos = text.find(section);
if (pos == std::string::npos) {
Expand All @@ -99,7 +103,7 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) {
std::string display = explicitNamespace
? (isDefaultNs ? shortName : std::format("{}:{}", ns, shortName))
: nameSpec;
mcpp::ui::status("Adding", std::format("{} v{} to dependencies", display, version));
mcpp::ui::status("Adding", std::format("{} v{} to {}", display, version, table));
std::println("");
std::println("Run `mcpp build` to fetch and build with the new dependency.");
return 0;
Expand Down
2 changes: 1 addition & 1 deletion src/toolchain/fingerprint.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import mcpp.toolchain.detect;

export namespace mcpp::toolchain {

inline constexpr std::string_view MCPP_VERSION = "0.0.64";
inline constexpr std::string_view MCPP_VERSION = "0.0.65";

struct FingerprintInputs {
Toolchain toolchain;
Expand Down
59 changes: 59 additions & 0 deletions tests/e2e/79_gtest_regular_dep_feature_main.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# requires:
# 79_gtest_regular_dep_feature_main.sh — gtest as a REGULAR dependency
# (`[dependencies]`, via `mcpp add gtest`) must NOT inject gtest_main into a
# `mcpp build` app that has its own main. Regression for issue #168
# (`gtest_main.o : error LNK2005: main already defined in main.o`).
#
# gtest_main.cc is gated behind the `main` feature in compat.gtest.lua: off by
# default (framework only) → no main collision; `features = ["main"]` opts in.
#
# No `requires:` capability → runs on all three CI platforms (the original bug
# was Windows/MSVC LNK2005). Depends on the published mcpp-index carrying the
# gtest `main` feature.
set -e

TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT

cd "$TMP"
"$MCPP" new app > /dev/null
cd app

# (1) #168: gtest in [dependencies] + app's own main → build must succeed, and
# gtest_main must NOT be linked.
"$MCPP" add gtest@1.15.2 > /dev/null
grep -q '^\[dependencies\]' mcpp.toml || { echo "FAIL: add did not write [dependencies]"; cat mcpp.toml; exit 1; }
"$MCPP" build > /dev/null || { echo "FAIL: #168 — build with regular-dep gtest failed"; exit 1; }
nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1)
if grep -q 'gtest_main' "$nj"; then
echo "FAIL: gtest_main linked into app by default (would collide with main)"; exit 1
fi
grep -q 'gtest-all' "$nj" || { echo "FAIL: gtest framework (gtest-all) not linked"; exit 1; }

# (2) opt-in: features = ["main"] + a TEST-only file (no own main) → gtest_main
# IS linked and provides the entry.
cat > mcpp.toml <<'EOF'
[package]
name = "app"
version = "0.1.0"

[dependencies]
gtest = { version = "1.15.2", features = ["main"] }
EOF
cat > src/main.cpp <<'EOF'
#include <gtest/gtest.h>
TEST(App, ok) { EXPECT_EQ(1 + 1, 2); }
EOF
"$MCPP" build > /dev/null || { echo "FAIL: features=[main] build failed"; exit 1; }
nj=$(find target -name build.ninja | xargs ls -t 2>/dev/null | head -1)
grep -q 'gtest_main' "$nj" || { echo "FAIL: features=[main] did not link gtest_main"; exit 1; }

# (3) `mcpp add --dev` routes to [dev-dependencies].
cd "$TMP"
"$MCPP" new libapp > /dev/null
cd libapp
"$MCPP" add --dev gtest@1.15.2 > /dev/null
grep -q '^\[dev-dependencies\]' mcpp.toml || { echo "FAIL: add --dev did not write [dev-dependencies]"; cat mcpp.toml; exit 1; }

echo "OK"
29 changes: 29 additions & 0 deletions tests/unit/test_manifest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,35 @@ package = {
EXPECT_EQ(m->targets[0].soname, "libtinyshared.so.1");
}

TEST(SynthesizeFromXpkgLua, FeatureGatedSources) {
// gtest-style: gtest_main.cc listed in base `sources` (old-mcpp compat) AND
// under the `main` feature → featureSources records it; the feature is
// registered in featuresMap. prepare_build later gates it (off by default).
constexpr auto src = R"(
package = {
spec = "1",
name = "gtestlike",
xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } },
mcpp = {
sources = { "*/src/all.cc", "*/src/main.cc" },
targets = { ["gtestlike"] = { kind = "lib" } },
features = {
["main"] = { sources = { "*/src/main.cc" } },
},
},
}
)";
auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0");
ASSERT_TRUE(m.has_value()) << m.error().format();
// base sources keep both (old mcpp ignores `features` → no regression)
ASSERT_EQ(m->buildConfig.sources.size(), 2u);
// the `main` feature is registered + carries its gated source
ASSERT_TRUE(m->featuresMap.contains("main"));
ASSERT_TRUE(m->buildConfig.featureSources.contains("main"));
ASSERT_EQ(m->buildConfig.featureSources.at("main").size(), 1u);
EXPECT_EQ(m->buildConfig.featureSources.at("main")[0], "*/src/main.cc");
}

TEST(SynthesizeFromXpkgLua, RuntimeConfig) {
constexpr auto src = R"(
package = {
Expand Down
Loading