Skip to content
Merged
457 changes: 457 additions & 0 deletions .agents/docs/2026-05-12-workspace-design.md

Large diffs are not rendered by default.

657 changes: 657 additions & 0 deletions .agents/docs/2026-05-12-workspace-implementation-plan.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
# Point the e2e runner at the freshly-built binary, not the
# bootstrap one. Tests cd into mktemp -d, so $MCPP must be
# absolute or the relative path breaks under the temp cwd.
MCPP=$(realpath "$(find target -type f -name mcpp | head -1)")
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
test -x "$MCPP"
export MCPP
# Tests that set MCPP_HOME to a fresh tmpdir need an xlings
Expand All @@ -117,6 +117,6 @@ jobs:

- name: Self-host smoke (freshly-built mcpp builds itself again)
run: |
MCPP=$(realpath "$(find target -type f -name mcpp | head -1)")
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
"$MCPP" build
"$MCPP" test
173 changes: 159 additions & 14 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,54 @@ std::optional<std::filesystem::path> find_manifest_root(std::filesystem::path st
}
}

// Find the workspace root by walking upward from a member directory.
// Returns empty if no workspace root found.
std::filesystem::path find_workspace_root(const std::filesystem::path& memberRoot) {
auto p = memberRoot.parent_path();
while (true) {
if (std::filesystem::exists(p / "mcpp.toml")) {
auto m = mcpp::manifest::load(p / "mcpp.toml");
if (m && m->workspace.present) {
// Verify memberRoot is in members list
auto rel = std::filesystem::relative(memberRoot, p);
for (auto& member : m->workspace.members) {
if (rel == std::filesystem::path(member)) return p;
}
}
}
auto parent = p.parent_path();
if (parent == p) break;
p = parent;
}
return {};
}

// Merge workspace.dependencies versions into a member's deps.
void merge_workspace_deps(mcpp::manifest::Manifest& member,
const mcpp::manifest::Manifest& workspace) {
auto merge_map = [&](std::map<std::string, mcpp::manifest::DependencySpec>& deps) {
for (auto& [name, spec] : deps) {
if (!spec.inheritWorkspace) continue;
// Try exact key match first
auto it = workspace.workspace.dependencies.find(name);
if (it != workspace.workspace.dependencies.end()) {
spec.version = it->second.version;
spec.inheritWorkspace = false;
continue;
}
// Try short name for default-ns deps
auto shortIt = workspace.workspace.dependencies.find(spec.shortName);
if (shortIt != workspace.workspace.dependencies.end()) {
spec.version = shortIt->second.version;
spec.inheritWorkspace = false;
}
}
};
merge_map(member.dependencies);
merge_map(member.devDependencies);
merge_map(member.buildDependencies);
}

std::filesystem::path target_dir(const mcpp::toolchain::Toolchain& tc,
const mcpp::toolchain::Fingerprint& fp,
const std::filesystem::path& root)
Expand Down Expand Up @@ -772,8 +820,9 @@ struct BuildContext {
// Command-level overrides (--target / --static).
// Empty defaults preserve pre-existing behaviour exactly.
struct BuildOverrides {
std::string target_triple; // empty = host triple, fall through to [toolchain]
bool force_static = false; // --static (or implied by musl target)
std::string target_triple; // empty = host triple, fall through to [toolchain]
bool force_static = false; // --static (or implied by musl target)
std::string package_filter; // -p <name>: only build this workspace member
};

// `prepare_build` builds the BuildContext for any verb that compiles.
Expand All @@ -795,6 +844,94 @@ prepare_build(bool print_fingerprint,
auto m = mcpp::manifest::load(*root / "mcpp.toml");
if (!m) return std::unexpected(m.error().format());

// ─── Workspace handling ────────────────────────────────────────────
// If the manifest has [workspace] and is a virtual workspace (no [package]),
// or if -p filter is set, switch to the target member's manifest.
std::optional<mcpp::manifest::Manifest> wsManifest; // keep workspace manifest alive
if (m->workspace.present) {
std::string targetMember;

if (!overrides.package_filter.empty()) {
// -p <name>: find matching member by directory basename or path
for (auto& mp : m->workspace.members) {
auto basename = std::filesystem::path(mp).filename().string();
if (basename == overrides.package_filter || mp == overrides.package_filter) {
targetMember = mp;
break;
}
}
if (targetMember.empty()) {
return std::unexpected(std::format(
"workspace member '{}' not found in [workspace].members",
overrides.package_filter));
}
} else if (m->package.name.empty()) {
// Virtual workspace: find a member with a binary target, or use last member.
for (auto& mp : m->workspace.members) {
auto memberDir = *root / mp;
auto mm = mcpp::manifest::load(memberDir / "mcpp.toml");
if (!mm) continue;
for (auto& t : mm->targets) {
if (t.kind == mcpp::manifest::Target::Binary) {
targetMember = mp;
break;
}
}
if (!targetMember.empty()) break;
}
if (targetMember.empty() && !m->workspace.members.empty()) {
targetMember = m->workspace.members.back();
}
}
// else: rooted workspace with [package] — build root normally.

if (!targetMember.empty()) {
auto memberDir = *root / targetMember;
if (!std::filesystem::exists(memberDir / "mcpp.toml")) {
return std::unexpected(std::format(
"workspace member '{}' has no mcpp.toml", targetMember));
}
wsManifest = std::move(*m); // preserve workspace manifest
m = mcpp::manifest::load(memberDir / "mcpp.toml");
if (!m) return std::unexpected(std::format(
"workspace member '{}': {}", targetMember, m.error().format()));

// Merge workspace dependency versions
merge_workspace_deps(*m, *wsManifest);

// Inherit workspace toolchain if member doesn't define one
if (m->toolchain.byPlatform.empty()) {
m->toolchain = wsManifest->toolchain;
}
// Inherit workspace target overrides
for (auto& [triple, entry] : wsManifest->targetOverrides) {
if (!m->targetOverrides.contains(triple)) {
m->targetOverrides[triple] = entry;
}
}

mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember));
root = memberDir;
}
} else {
// Not at workspace root — check if we're inside a workspace
auto wsRoot = find_workspace_root(*root);
if (!wsRoot.empty()) {
auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml");
if (wsm && wsm->workspace.present) {
merge_workspace_deps(*m, *wsm);
if (m->toolchain.byPlatform.empty()) {
m->toolchain = wsm->toolchain;
}
for (auto& [triple, entry] : wsm->targetOverrides) {
if (!m->targetOverrides.contains(triple)) {
m->targetOverrides[triple] = entry;
}
}
}
}
}

// Inject synthetic targets (e.g. test binaries from `mcpp test`).
for (auto& t : extraTargets) m->targets.push_back(t);

Expand Down Expand Up @@ -1073,6 +1210,7 @@ prepare_build(bool print_fingerprint,
std::string requestedBy; // who asked for it
std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge)
std::size_t consumerDepIndex; // dep_manifests slot of who pushed this child; kMainConsumer for main
std::filesystem::path resolveRoot; // base dir for relative path deps (empty = use project root)
};
std::deque<WorkItem> worklist;

Expand Down Expand Up @@ -1317,12 +1455,12 @@ prepare_build(bool print_fingerprint,
// caller wants them; they're never propagated transitively.
const std::string mainPkgLabel = m->package.name;
for (auto& [n, s] : m->dependencies) {
worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer});
worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer, {}});
}
if (includeDevDeps) {
for (auto& [n, s] : m->devDependencies) {
worklist.push_back({n, s, mainPkgLabel + " (dev-dep)",
s.version, kMainConsumer});
s.version, kMainConsumer, {}});
}
}

Expand Down Expand Up @@ -1529,14 +1667,15 @@ prepare_build(bool print_fingerprint,
{
const std::string& expectedShort =
spec.shortName.empty() ? name : spec.shortName;
std::string expectedComposite;
if (!spec.namespace_.empty()
&& spec.namespace_ != mcpp::manifest::kDefaultNamespace) {
expectedComposite = std::format("{}.{}",
spec.namespace_, expectedShort);
}
// Also accept the fully-qualified form (ns.short) since
// synthesize_from_xpkg_lua may set package.name to the
// composite name for backward compat.
auto expectedComposite = spec.namespace_.empty()
? std::string{}
: std::format("{}.{}", spec.namespace_, expectedShort);
const bool nameOk =
newManifest.package.name == expectedShort
|| newManifest.package.name == name
|| (!expectedComposite.empty()
&& newManifest.package.name == expectedComposite);
if (!nameOk) {
Expand Down Expand Up @@ -1571,7 +1710,7 @@ prepare_build(bool print_fingerprint,
dep_manifests[it->second.depIndex]->dependencies) {
worklist.push_back({child_name, child_spec, newLabel,
child_spec.version,
it->second.depIndex});
it->second.depIndex, {}});
}
continue;
}
Expand All @@ -1583,9 +1722,12 @@ prepare_build(bool print_fingerprint,
std::filesystem::path dep_root;

if (spec.isPath()) {
// Path-based: resolve relative to project root.
// Path-based: resolve relative to the consumer's root dir.
// For top-level deps this is the project root; for transitive
// deps it's the parent dep's directory (stored in resolveRoot).
dep_root = spec.path;
if (dep_root.is_relative()) dep_root = *root / dep_root;
auto base = item.resolveRoot.empty() ? *root : item.resolveRoot;
if (dep_root.is_relative()) dep_root = base / dep_root;
dep_root = std::filesystem::weakly_canonical(dep_root);
} else if (spec.isGit()) {
// Git-based (M4 #5): clone into ~/.mcpp/git/<hash>/<rev>/
Expand Down Expand Up @@ -1720,7 +1862,7 @@ prepare_build(bool print_fingerprint,
const std::size_t selfIdx = dep_manifests.size() - 1;
for (auto& [child_name, child_spec] : dep_manifests.back()->dependencies) {
worklist.push_back({child_name, child_spec, thisDepLabel,
child_spec.version, selfIdx});
child_spec.version, selfIdx, dep_root});
}
}

Expand Down Expand Up @@ -2053,6 +2195,7 @@ int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {

BuildOverrides ov;
if (auto t = parsed.value("target")) ov.target_triple = *t;
if (auto p = parsed.value("package")) ov.package_filter = *p;
ov.force_static = parsed.is_flag_set("static");

// P0: try fast-path if inputs haven't changed.
Expand Down Expand Up @@ -3533,6 +3676,8 @@ int run(int argc, char** argv) {
"Build for <triple> (e.g. x86_64-linux-musl); looks up [target.<triple>] in mcpp.toml"))
.option(cl::Option("static").help(
"Force static linking (-static). On Linux, prefer pairing with --target <arch>-linux-musl"))
.option(cl::Option("package").short_name('p').takes_value().value_name("NAME")
.help("Build only the named workspace member"))
.action(wrap_rc(cmd_build)))
.subcommand(cl::App("run")
.description("Build + run a binary target (after `--`, args are passed to it)")
Expand Down
Loading
Loading