Skip to content

Commit 8e89174

Browse files
authored
feat: workspace support (Phase 1) (#27)
* feat(workspace): parse [workspace] section and .workspace = true - Add WorkspaceConfig struct to Manifest (members, exclude, dependencies) - Parse [workspace] section with members array and version declarations - Parse [workspace.dependencies] and [workspace.dependencies.<ns>] - Support .workspace = true in dependency specs for version inheritance - Make [package] optional when [workspace] is present (virtual workspace) - Add 3 unit tests for workspace parsing * feat(workspace): discovery, build orchestration, -p flag, e2e test - Add find_workspace_root() for upward workspace discovery - Add merge_workspace_deps() for .workspace = true resolution - Virtual workspace: auto-select binary member as build target - Workspace toolchain/target overrides inherit to members - -p/--package flag for selective member builds - Members inside a workspace auto-inherit workspace config - Add e2e test: 3-member workspace (core + greeter + hello) * docs: workspace design and implementation plan * ci: re-trigger (clear target cache) * ci: find newest mcpp binary (avoids stale cached binary) * fix: transitive path deps resolve relative to parent dep's root Path dependencies of transitive deps were resolved relative to the main project root, which fails for workspace members when greeter depends on core via path = "../core" but the build target is apps/hello. Fix: add resolveRoot to WorkItem so child deps resolve relative to their parent dep's directory. Also fix CI to pick the newest built mcpp binary (avoids stale cached binaries from different fingerprints). * fix(test): look for workspace binary in member's target dir * fix(e2e): update tests for 0.0.10 changes - 05_errors.sh: remove naming prefix enforcement test (relaxed in 0.0.10) - 12_add_command.sh: mcpplibs is now default ns, no subtable header - 21_ninja_dyndep.sh: P1 per-file dyndep replaces global build.ninja.dd * fix: name matching for default-ns deps + forbidden module name in test - Name check in SemVer merge path now also accepts the map key name (handles synthesize_from_xpkg_lua setting composite package.name) - Rename test module from 'util' to 'acme.util' (util is a forbidden top-level name since 0.0.10)
1 parent a3012cc commit 8e89174

12 files changed

Lines changed: 1535 additions & 52 deletions

.agents/docs/2026-05-12-workspace-design.md

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-05-12-workspace-implementation-plan.md

Lines changed: 657 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ jobs:
101101
# Point the e2e runner at the freshly-built binary, not the
102102
# bootstrap one. Tests cd into mktemp -d, so $MCPP must be
103103
# absolute or the relative path breaks under the temp cwd.
104-
MCPP=$(realpath "$(find target -type f -name mcpp | head -1)")
104+
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
105105
test -x "$MCPP"
106106
export MCPP
107107
# Tests that set MCPP_HOME to a fresh tmpdir need an xlings
@@ -117,6 +117,6 @@ jobs:
117117
118118
- name: Self-host smoke (freshly-built mcpp builds itself again)
119119
run: |
120-
MCPP=$(realpath "$(find target -type f -name mcpp | head -1)")
120+
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
121121
"$MCPP" build
122122
"$MCPP" test

src/cli.cppm

Lines changed: 159 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,54 @@ std::optional<std::filesystem::path> find_manifest_root(std::filesystem::path st
113113
}
114114
}
115115

116+
// Find the workspace root by walking upward from a member directory.
117+
// Returns empty if no workspace root found.
118+
std::filesystem::path find_workspace_root(const std::filesystem::path& memberRoot) {
119+
auto p = memberRoot.parent_path();
120+
while (true) {
121+
if (std::filesystem::exists(p / "mcpp.toml")) {
122+
auto m = mcpp::manifest::load(p / "mcpp.toml");
123+
if (m && m->workspace.present) {
124+
// Verify memberRoot is in members list
125+
auto rel = std::filesystem::relative(memberRoot, p);
126+
for (auto& member : m->workspace.members) {
127+
if (rel == std::filesystem::path(member)) return p;
128+
}
129+
}
130+
}
131+
auto parent = p.parent_path();
132+
if (parent == p) break;
133+
p = parent;
134+
}
135+
return {};
136+
}
137+
138+
// Merge workspace.dependencies versions into a member's deps.
139+
void merge_workspace_deps(mcpp::manifest::Manifest& member,
140+
const mcpp::manifest::Manifest& workspace) {
141+
auto merge_map = [&](std::map<std::string, mcpp::manifest::DependencySpec>& deps) {
142+
for (auto& [name, spec] : deps) {
143+
if (!spec.inheritWorkspace) continue;
144+
// Try exact key match first
145+
auto it = workspace.workspace.dependencies.find(name);
146+
if (it != workspace.workspace.dependencies.end()) {
147+
spec.version = it->second.version;
148+
spec.inheritWorkspace = false;
149+
continue;
150+
}
151+
// Try short name for default-ns deps
152+
auto shortIt = workspace.workspace.dependencies.find(spec.shortName);
153+
if (shortIt != workspace.workspace.dependencies.end()) {
154+
spec.version = shortIt->second.version;
155+
spec.inheritWorkspace = false;
156+
}
157+
}
158+
};
159+
merge_map(member.dependencies);
160+
merge_map(member.devDependencies);
161+
merge_map(member.buildDependencies);
162+
}
163+
116164
std::filesystem::path target_dir(const mcpp::toolchain::Toolchain& tc,
117165
const mcpp::toolchain::Fingerprint& fp,
118166
const std::filesystem::path& root)
@@ -772,8 +820,9 @@ struct BuildContext {
772820
// Command-level overrides (--target / --static).
773821
// Empty defaults preserve pre-existing behaviour exactly.
774822
struct BuildOverrides {
775-
std::string target_triple; // empty = host triple, fall through to [toolchain]
776-
bool force_static = false; // --static (or implied by musl target)
823+
std::string target_triple; // empty = host triple, fall through to [toolchain]
824+
bool force_static = false; // --static (or implied by musl target)
825+
std::string package_filter; // -p <name>: only build this workspace member
777826
};
778827

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

847+
// ─── Workspace handling ────────────────────────────────────────────
848+
// If the manifest has [workspace] and is a virtual workspace (no [package]),
849+
// or if -p filter is set, switch to the target member's manifest.
850+
std::optional<mcpp::manifest::Manifest> wsManifest; // keep workspace manifest alive
851+
if (m->workspace.present) {
852+
std::string targetMember;
853+
854+
if (!overrides.package_filter.empty()) {
855+
// -p <name>: find matching member by directory basename or path
856+
for (auto& mp : m->workspace.members) {
857+
auto basename = std::filesystem::path(mp).filename().string();
858+
if (basename == overrides.package_filter || mp == overrides.package_filter) {
859+
targetMember = mp;
860+
break;
861+
}
862+
}
863+
if (targetMember.empty()) {
864+
return std::unexpected(std::format(
865+
"workspace member '{}' not found in [workspace].members",
866+
overrides.package_filter));
867+
}
868+
} else if (m->package.name.empty()) {
869+
// Virtual workspace: find a member with a binary target, or use last member.
870+
for (auto& mp : m->workspace.members) {
871+
auto memberDir = *root / mp;
872+
auto mm = mcpp::manifest::load(memberDir / "mcpp.toml");
873+
if (!mm) continue;
874+
for (auto& t : mm->targets) {
875+
if (t.kind == mcpp::manifest::Target::Binary) {
876+
targetMember = mp;
877+
break;
878+
}
879+
}
880+
if (!targetMember.empty()) break;
881+
}
882+
if (targetMember.empty() && !m->workspace.members.empty()) {
883+
targetMember = m->workspace.members.back();
884+
}
885+
}
886+
// else: rooted workspace with [package] — build root normally.
887+
888+
if (!targetMember.empty()) {
889+
auto memberDir = *root / targetMember;
890+
if (!std::filesystem::exists(memberDir / "mcpp.toml")) {
891+
return std::unexpected(std::format(
892+
"workspace member '{}' has no mcpp.toml", targetMember));
893+
}
894+
wsManifest = std::move(*m); // preserve workspace manifest
895+
m = mcpp::manifest::load(memberDir / "mcpp.toml");
896+
if (!m) return std::unexpected(std::format(
897+
"workspace member '{}': {}", targetMember, m.error().format()));
898+
899+
// Merge workspace dependency versions
900+
merge_workspace_deps(*m, *wsManifest);
901+
902+
// Inherit workspace toolchain if member doesn't define one
903+
if (m->toolchain.byPlatform.empty()) {
904+
m->toolchain = wsManifest->toolchain;
905+
}
906+
// Inherit workspace target overrides
907+
for (auto& [triple, entry] : wsManifest->targetOverrides) {
908+
if (!m->targetOverrides.contains(triple)) {
909+
m->targetOverrides[triple] = entry;
910+
}
911+
}
912+
913+
mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember));
914+
root = memberDir;
915+
}
916+
} else {
917+
// Not at workspace root — check if we're inside a workspace
918+
auto wsRoot = find_workspace_root(*root);
919+
if (!wsRoot.empty()) {
920+
auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml");
921+
if (wsm && wsm->workspace.present) {
922+
merge_workspace_deps(*m, *wsm);
923+
if (m->toolchain.byPlatform.empty()) {
924+
m->toolchain = wsm->toolchain;
925+
}
926+
for (auto& [triple, entry] : wsm->targetOverrides) {
927+
if (!m->targetOverrides.contains(triple)) {
928+
m->targetOverrides[triple] = entry;
929+
}
930+
}
931+
}
932+
}
933+
}
934+
798935
// Inject synthetic targets (e.g. test binaries from `mcpp test`).
799936
for (auto& t : extraTargets) m->targets.push_back(t);
800937

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

@@ -1317,12 +1455,12 @@ prepare_build(bool print_fingerprint,
13171455
// caller wants them; they're never propagated transitively.
13181456
const std::string mainPkgLabel = m->package.name;
13191457
for (auto& [n, s] : m->dependencies) {
1320-
worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer});
1458+
worklist.push_back({n, s, mainPkgLabel, s.version, kMainConsumer, {}});
13211459
}
13221460
if (includeDevDeps) {
13231461
for (auto& [n, s] : m->devDependencies) {
13241462
worklist.push_back({n, s, mainPkgLabel + " (dev-dep)",
1325-
s.version, kMainConsumer});
1463+
s.version, kMainConsumer, {}});
13261464
}
13271465
}
13281466

@@ -1529,14 +1667,15 @@ prepare_build(bool print_fingerprint,
15291667
{
15301668
const std::string& expectedShort =
15311669
spec.shortName.empty() ? name : spec.shortName;
1532-
std::string expectedComposite;
1533-
if (!spec.namespace_.empty()
1534-
&& spec.namespace_ != mcpp::manifest::kDefaultNamespace) {
1535-
expectedComposite = std::format("{}.{}",
1536-
spec.namespace_, expectedShort);
1537-
}
1670+
// Also accept the fully-qualified form (ns.short) since
1671+
// synthesize_from_xpkg_lua may set package.name to the
1672+
// composite name for backward compat.
1673+
auto expectedComposite = spec.namespace_.empty()
1674+
? std::string{}
1675+
: std::format("{}.{}", spec.namespace_, expectedShort);
15381676
const bool nameOk =
15391677
newManifest.package.name == expectedShort
1678+
|| newManifest.package.name == name
15401679
|| (!expectedComposite.empty()
15411680
&& newManifest.package.name == expectedComposite);
15421681
if (!nameOk) {
@@ -1571,7 +1710,7 @@ prepare_build(bool print_fingerprint,
15711710
dep_manifests[it->second.depIndex]->dependencies) {
15721711
worklist.push_back({child_name, child_spec, newLabel,
15731712
child_spec.version,
1574-
it->second.depIndex});
1713+
it->second.depIndex, {}});
15751714
}
15761715
continue;
15771716
}
@@ -1583,9 +1722,12 @@ prepare_build(bool print_fingerprint,
15831722
std::filesystem::path dep_root;
15841723

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

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

20542196
BuildOverrides ov;
20552197
if (auto t = parsed.value("target")) ov.target_triple = *t;
2198+
if (auto p = parsed.value("package")) ov.package_filter = *p;
20562199
ov.force_static = parsed.is_flag_set("static");
20572200

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

0 commit comments

Comments
 (0)