WsAddMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.PomRewriter;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.reconcile.FeatureVersionReconciler;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.Subproject;
import network.ike.workspace.Dependency;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.PublishedArtifactSet;
import network.ike.workspace.VersionSupport;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import javax.xml.parsers.DocumentBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Add a subproject repository to an existing workspace.
*
* <p>Given a git URL, this goal:
* <ol>
* <li>Clones the repository into the workspace</li>
* <li>Derives the subproject name from the URL (or accepts
* {@code -Dsubproject=<name>})</li>
* <li>Scans the POM to derive groupId and inter-subproject
* dependencies (matching dependency/parent groupIds against
* already-registered workspace subprojects)</li>
* <li>Appends a subproject entry to workspace.yaml</li>
* <li>Adds a top-level {@code <subprojects>} entry to the reactor POM
* (IKE-Network/ike-issues#696; {@code SubprojectPruneTransformer}
* prunes it at model-read time until the repo is cloned)</li>
* <li>Re-scans existing subprojects to discover any that depend
* on the newly added subproject (backward resolution)</li>
* </ol>
*
* <p>The subproject name is derived from the last path segment of the
* URL with {@code .git} stripped. For example,
* {@code https://github.com/ikmdev/tinkar-core.git} becomes
* {@code tinkar-core}.
*
* <pre>{@code
* mvn ws:add -Drepo=https://github.com/ikmdev/tinkar-core.git
* mvn ws:add -Drepo=https://github.com/ikmdev/rocks-kb.git
* mvn ws:add -Drepo=https://github.com/ikmdev/komet.git
* }</pre>
*
* @see WsScaffoldInitMojo for creating a new workspace or cloning all subprojects
*/
@Mojo(name = "add", projectRequired = false, aggregator = true)
public class WsAddMojo extends AbstractWorkspaceMojo {
/**
* Git repository URL. Prompted interactively if omitted.
*/
@Parameter(property = "repo")
private String repo;
/**
* Subproject name override. If omitted, derived from the repo URL
* (last path segment minus {@code .git}).
*/
@Parameter(property = "subproject")
private String subproject;
/**
* Short description of the subproject.
*/
@Parameter(property = "description")
private String description;
/**
* Branch to track. If omitted, the new subproject is placed on the
* workspace repo's current git branch (the workspace's active branch).
* If the workspace repo has no git state, falls back to the manifest's
* {@code defaults.branch}.
*
* <p>Passing an explicit {@code -Dbranch=} value that disagrees with
* the workspace repo's current branch is rejected — heterogeneous
* branch state across a workspace is not a supported configuration
* (see ike-issues#286).
*/
@Parameter(property = "branch")
private String branch;
/**
* Maven groupId for the subproject. If omitted, left as
* a placeholder in workspace.yaml.
*/
@Parameter(property = "groupId")
private String groupId;
/**
* Maven version for the subproject. If omitted, derived from
* the subproject's root POM. Written to workspace.yaml so that
* {@code ws:feature-start} can branch-qualify it.
*/
@Parameter(property = "version")
private String version;
/**
* Skip cloning — register the subproject in workspace.yaml without
* cloning. Dependencies cannot be derived without a POM to scan,
* so they will be empty. Use {@code ws:scaffold-init} to clone later.
*/
@Parameter(property = "skipClone", defaultValue = "false")
private boolean skipClone;
/**
* Derived dependency: target subproject, optional version-property
* name, and the derived relationship — {@code build} for
* parent/dependency/BOM-import/plugin-GAV references, {@code bundle}
* for plugin-staged references (a plugin's own {@code <dependencies>}
* or {@code <artifactItem>} entries; ike-issues#965).
*/
record DerivedDep(String subproject, String versionProperty,
String relationship) {
DerivedDep(String subproject, String versionProperty) {
this(subproject, versionProperty, "build");
}
}
/**
* A referenced coordinate together with the module POM that
* references it — source attribution for a derived edge, used by
* the {@code depends-on} acyclicity gate's diagnostic
* (IKE-Network/ike-issues#962).
*/
record PomRef(String coordinate, Path pomFile) {}
/**
* Result of {@link #deriveDependenciesDetailed}: the derived
* {@code depends-on} entries plus, per producing subproject, the
* module POMs whose references create the repo-level edge.
*/
record Derivation(
List<DerivedDep> deps,
Map<String, List<PomRef>> producerSources) {}
/** Creates this goal instance. */
public WsAddMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
repo = requireParam(repo, "repo", "Git repository URL");
// Resolve workspace root
Path wsDir = findWorkspaceRoot();
Path manifestPath = wsDir.resolve("workspace.yaml");
Path pomPath = wsDir.resolve("pom.xml");
if (!Files.exists(manifestPath)) {
throw new MojoException(
"No workspace.yaml found in " + wsDir
+ ". Run " + WsGoal.SCAFFOLD_INIT.qualified() + " first.");
}
// COORDINATING preflight (#780): the workspace-root tree must be
// unmodified so the add commit below is attributable solely to this
// goal. Root-only (empty subproject list) — the new clone carries its
// own branch WIP, and its version-alignment edits are intentionally
// left uncommitted. -Dallow-uncommitted bypasses; -Ddefer-commit (a
// cascade caller owns the commit) skips it too.
if (!allowUncommitted() && !deferCommit()) {
Preflight.of(List.of(PreflightCondition.WORKING_TREE_CLEAN),
PreflightContext.of(wsDir.toFile(), null, List.of()))
.requirePassed(WsGoal.ADD);
}
// Resolve the target branch up front: workspace repo HEAD is
// authoritative (ike-issues#286). The new subproject must land
// on the same branch as the rest of the workspace.
branch = resolveBranch(wsDir, manifestPath);
// Derive subproject name from URL if not specified
if (subproject == null || subproject.isBlank()) {
subproject = deriveSubprojectName(repo);
}
// Validate via SubprojectName (#295). The derived name comes
// from a git URL's last path segment which usually conforms,
// but explicit -Dsubproject= and odd repo URLs can fail.
validateSubprojectName(subproject);
// Check if already registered — if so, re-derive and update
// rather than appending a duplicate (idempotent behavior)
boolean alreadyRegistered = false;
try {
Manifest existing = ManifestReader.read(manifestPath);
alreadyRegistered = existing.subprojects().containsKey(subproject);
} catch (ManifestException e) {
// Manifest may be empty/malformed on first add — continue
}
if (description == null || description.isBlank()) {
description = subproject + " subproject.";
}
// Clone so we can scan the POM for groupId and dependencies
Path subprojectDir = wsDir.resolve(subproject);
boolean cloned = false;
List<DerivedDep> derivedDeps = null;
if (!skipClone && !Files.exists(subprojectDir)) {
cloneSubproject(wsDir);
cloned = true;
} else if (Files.exists(subprojectDir.resolve(".git"))) {
// A pre-existing checkout must honor the same workspace-branch-coherence
// rule as a fresh clone — before this alignment, this path silently kept
// whatever branch the checkout happened to be on, and qualifyForBranch
// then edited the POM there (IKE-Network/ike-issues#902).
alignExistingCheckout(subprojectDir);
}
String detectedParent = null;
if (Files.exists(subprojectDir.resolve("pom.xml"))) {
// Derive groupId from POM if not explicitly specified
if (groupId == null || groupId.isBlank()) {
groupId = deriveGroupId(subprojectDir);
}
// Derive version from POM if not explicitly specified
if (version == null || version.isBlank()) {
try {
version = ReleaseSupport.readPomVersion(
subprojectDir.resolve("pom.xml").toFile());
} catch (MojoException e) {
// Non-fatal — version will be null in manifest
}
}
// Branch-qualify on a feature branch so the added subproject
// is isolated like the rest (IKE-Network/ike-issues#574).
// No-op on main; scaffold-publish's FeatureVersionReconciler
// self-heals if this is skipped, so failures are non-fatal.
try {
version = qualifyForBranch(subprojectDir, version, branch);
} catch (IOException e) {
getLog().warn(" Could not branch-qualify version: "
+ e.getMessage());
}
// Detect parent POM — record parent: only when the POM's
// <parent> matches a workspace subproject by full GA (groupId
// AND artifactId). An external parent that merely shares a
// groupId with a sibling (e.g. network.ike.platform:ike-parent
// alongside a network.ike.platform:* sibling) must not be
// recorded as that sibling — ike-issues#565.
PomParentSupport.ParentInfo parentInfo = null;
try {
parentInfo = PomParentSupport.readParent(
subprojectDir.resolve("pom.xml"));
detectedParent = detectWorkspaceParent(
wsDir, manifestPath, parentInfo);
} catch (Exception e) {
// Non-fatal — parent detection is best-effort
}
// #324 parent coherence: if the new subproject's
// <parent> GA matches the workspace aggregator's own
// <parent> GA, enforce two rules:
// 1. Version coherence — same version as workspace
// 2. Cycle prevention — empty <relativePath/>
// Warn (not fail) at add time so an operator who hits
// either violation gets the heads-up before running
// mvn install. The matching ws:scaffold-draft check
// (which folds verify per #393) is authoritative —
// failure mode there is what blocks a release.
if (parentInfo != null) {
checkParentCoherenceAtAdd(wsDir, subprojectDir,
subproject, parentInfo);
}
// Derive dependencies by matching POM groupIds against
// already-registered workspace subprojects
try {
derivedDeps = deriveDependencies(wsDir, manifestPath,
subprojectDir, subproject);
} catch (IOException e) {
getLog().warn(" Could not derive dependencies from POM: "
+ e.getMessage());
}
} else if (!skipClone) {
getLog().warn(" No pom.xml found — dependencies not derived");
}
getLog().info("");
String wsName = readWorkspaceName(wsDir);
getLog().info(wsName + " — Add Subproject");
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Subproject: " + subproject);
getLog().info(" Repo: " + repo);
if (branch != null) {
getLog().info(" Branch: " + branch);
}
if (version != null && !version.isBlank()) {
getLog().info(" Version: " + version);
}
if (groupId != null && !groupId.isBlank()) {
getLog().info(" GroupId: " + groupId);
}
if (detectedParent != null) {
getLog().info(" Parent: " + detectedParent + " (detected from POM)");
}
if (derivedDeps != null && !derivedDeps.isEmpty()) {
String depNames = derivedDeps.stream()
.map(DerivedDep::subproject)
.collect(Collectors.joining(", "));
getLog().info(" Depends: " + depNames + " (derived from POM)");
} else {
getLog().info(" Depends: (none)");
}
if (alreadyRegistered) {
getLog().info(" (already registered — re-validating dependencies)");
}
if (cloned) {
getLog().info(Ansi.green(" ✓ ") + "Cloned " + subproject);
}
getLog().info("");
// Snapshot the root files BEFORE editing them so the commit below is
// scoped to exactly what this goal authored (#780, IN_ISOLATION),
// and so the cycle gate below can restore them (#962).
String manifestBefore;
String pomBefore;
try {
manifestBefore = Files.readString(manifestPath, StandardCharsets.UTF_8);
pomBefore = Files.readString(pomPath, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Cannot read workspace files: " + e.getMessage(), e);
}
GoalAuthoredChanges authored = GoalAuthoredChanges.snapshot(
wsDir.toFile(), getLog(), "workspace.yaml", "pom.xml");
try {
if (alreadyRegistered) {
// Update existing entry's depends-on in workspace.yaml
updateSubprojectDependencies(manifestPath, subproject, derivedDeps);
getLog().info(Ansi.green(" ✓ ") + "workspace.yaml updated (dependencies re-derived)");
} else {
// Append new subproject to workspace.yaml
appendSubprojectToManifest(manifestPath, derivedDeps, detectedParent);
getLog().info(Ansi.green(" ✓ ") + "workspace.yaml updated");
}
// Reactor membership is a top-level <subprojects> entry now
// (#696); addSubprojectToPom is idempotent.
addSubprojectToPom(pomPath);
getLog().info(Ansi.green(" ✓ ") + "pom.xml updated (subproject: " + subproject + ")");
} catch (IOException e) {
throw new MojoException(
"Failed to update workspace files: " + e.getMessage(), e);
}
// Backward resolution: check if any existing subprojects
// depend on the newly added subproject's groupId
if (Files.exists(subprojectDir.resolve("pom.xml"))) {
try {
int backfilled = backfillDependencies(
wsDir, manifestPath, subproject, subprojectDir);
if (backfilled > 0) {
getLog().info(Ansi.green(" ✓ ") + "Updated " + backfilled
+ " existing subproject(s) with dependency on "
+ subproject);
}
} catch (IOException e) {
getLog().warn(" Could not backfill dependencies: "
+ e.getMessage());
}
}
// Acyclicity gate (#962): the derived forward edges plus the
// backfilled reverse edges are all in workspace.yaml now. If they
// contracted into a repo-level cycle, restore the pre-add files and
// fail — a committed cycle would block every subsequent ws: goal.
failOnDependsOnCycle(getLog(), wsDir, manifestPath, pomPath,
manifestBefore, pomBefore);
// Commit the root edits in isolation (#780, IN_ISOLATION): only the
// paths this goal authored (workspace.yaml + pom.xml), which the
// preflight guaranteed were clean. Skipped under -Ddefer-commit, where
// a cascade caller owns the commit.
if (!deferCommit()) {
if (authored.commitAuthored("workspace: add " + subproject
+ "\n\nRefs: IKE-Network/ike-issues#780")) {
getLog().info(Ansi.green(" ✓ ") + "committed workspace.yaml + pom.xml");
}
}
// Version alignment: update dependency versions in the newly
// added subproject (and any backfilled subprojects) to match
// workspace SNAPSHOT versions. Changes are left uncommitted
// so the developer can review and fold them into a feature branch.
if (Files.exists(subprojectDir.resolve("pom.xml"))) {
try {
Manifest updatedManifest = ManifestReader.read(manifestPath);
int aligned = alignVersions(wsDir, subprojectDir, subproject,
updatedManifest);
if (aligned > 0) {
getLog().info("");
getLog().info(Ansi.yellow(" ⚠ ") + aligned + " file(s) modified for version "
+ "alignment (uncommitted)");
getLog().info(" Review with 'git diff' in " + subproject);
}
} catch (IOException e) {
getLog().warn(" Could not align versions: " + e.getMessage());
}
}
getLog().info("");
if (cloned) {
getLog().info(" Subproject added and cloned.");
} else {
getLog().info(" Subproject added. Run 'mvn "
+ WsGoal.SCAFFOLD_INIT.qualified() + "' to clone.");
}
getLog().info("");
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("Added subproject **" + subproject + "**");
report.table(List.of("Field", "Value"), List.of(
new String[]{"Repo", repo},
new String[]{"Cloned",
cloned ? "yes"
: "no — run " + WsGoal.SCAFFOLD_INIT.qualified()}));
WorkspaceReportSpec spec = new WorkspaceReportSpec(WsGoal.ADD,
report.build());
PostMutationSync.refresh(workspaceRoot(), getLog());
return spec;
}
// ── Feature-branch version qualification (#574) ──────────────
/**
* On a feature branch, branch-qualify the added subproject's version
* and rewrite its POM so it is isolated like the rest of the
* workspace (IKE-Network/ike-issues#574). No-op on {@code main} and
* when already qualified ({@link VersionSupport#branchQualifiedVersion}
* is idempotent). {@code scaffold-publish}'s
* {@code FeatureVersionReconciler} self-heals if this is skipped, so
* the caller treats a rewrite failure as non-fatal. Package-private
* for tests.
*
* @param subprojectDir the cloned subproject directory
* @param version the version about to be recorded (may be null)
* @param branch the branch the subproject will track
* @return the version to record — branch-qualified on a feature branch
* @throws IOException if the subproject POM cannot be rewritten
*/
static String qualifyForBranch(Path subprojectDir, String version,
String branch) throws IOException {
if (version == null || version.isBlank()
|| branch == null || branch.isBlank()
|| "main".equals(branch)) {
return version;
}
String qualified =
VersionSupport.branchQualifiedVersion(version, branch);
if (!qualified.equals(version)) {
Path pom = subprojectDir.resolve("pom.xml");
if (Files.exists(pom)) {
FeatureVersionReconciler.rewriteOwnVersion(
pom, version, qualified);
}
}
return qualified;
}
// ── YAML generation ──────────────────────────────────────────
void appendSubprojectToManifest(Path manifestPath, List<DerivedDep> derivedDeps,
String detectedParent)
throws IOException {
String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
StringBuilder entry = new StringBuilder();
entry.append("\n ").append(subproject).append(":\n");
entry.append(" description: >\n");
entry.append(" ").append(description).append("\n");
entry.append(" repo: ").append(repo).append("\n");
if (branch != null && !branch.isBlank()) {
entry.append(" branch: ").append(branch).append("\n");
}
if (version != null && !version.isBlank()) {
entry.append(" version: \"").append(version).append("\"\n");
}
if (groupId != null && !groupId.isBlank()) {
entry.append(" groupId: ").append(groupId).append("\n");
}
if (detectedParent != null) {
entry.append(" parent: ").append(detectedParent).append("\n");
}
if (derivedDeps != null && !derivedDeps.isEmpty()) {
entry.append(" depends-on:\n");
for (DerivedDep dep : derivedDeps) {
entry.append(" - subproject: ").append(dep.subproject()).append("\n");
entry.append(" relationship: build\n");
if (dep.versionProperty() != null) {
entry.append(" version-property: ").append(dep.versionProperty()).append("\n");
}
}
} else {
entry.append(" depends-on: []\n");
}
// Insert at the end of the subprojects: block, before any
// trailing top-level constructs (e.g. the `# ide:` template
// comment, or a future `ide:` key). #240
int insertAt = findSubprojectsBlockInsertionPoint(yaml);
if (insertAt < 0) {
// No subprojects: key found — append at EOF as fallback.
yaml = yaml + entry;
} else {
yaml = yaml.substring(0, insertAt) + entry + yaml.substring(insertAt);
}
Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
}
/**
* Locate the position inside {@code workspace.yaml} where a new
* subproject entry should be inserted. The entry belongs at the
* end of the {@code subprojects:} block — after any existing
* subproject entries (and the placeholder comment) — and before
* any subsequent top-level construct such as a comment block or
* a sibling top-level key (e.g. {@code ide:}).
*
* <p>Operationally: find the {@code subprojects:} line, scan
* forward through indented and blank lines (which belong to the
* block), and stop at the first column-0 non-blank line. The
* insertion point is the byte offset right after the last
* non-blank line of the block.
*
* @param yaml the current manifest text
* @return offset to insert before, or {@code -1} if no
* {@code subprojects:} key was found
*/
static int findSubprojectsBlockInsertionPoint(String yaml) {
// Locate the subprojects: line.
int subprojectsLineStart = -1;
int pos = 0;
while (pos < yaml.length()) {
int eol = yaml.indexOf('\n', pos);
if (eol < 0) eol = yaml.length();
String line = yaml.substring(pos, eol);
if (line.startsWith("subprojects:")) {
subprojectsLineStart = pos;
pos = (eol < yaml.length()) ? eol + 1 : eol;
break;
}
pos = (eol < yaml.length()) ? eol + 1 : eol;
}
if (subprojectsLineStart < 0) return -1;
// Walk forward: indented and blank lines belong to the block;
// a column-0 non-blank line ends it. Track the end-offset of
// the last non-blank line so the insertion point is just after it.
int lastNonBlankEnd = pos;
while (pos < yaml.length()) {
int eol = yaml.indexOf('\n', pos);
boolean hasNewline = eol >= 0;
if (eol < 0) eol = yaml.length();
String line = yaml.substring(pos, eol);
int nextStart = hasNewline ? eol + 1 : eol;
if (line.isBlank()) {
// Could be inside the block or trailing — keep scanning.
} else if (!Character.isWhitespace(line.charAt(0))) {
// Reached the next top-level construct — stop.
return lastNonBlankEnd;
} else {
// Indented content line — extend the block.
lastNonBlankEnd = nextStart;
}
pos = nextStart;
if (!hasNewline) break;
}
// Hit EOF before another top-level construct.
return lastNonBlankEnd;
}
/**
* Update the depends-on section for an existing subproject in
* workspace.yaml by merging the newly derived build edges into the
* current block: hand-declared non-build entries and their comments
* are preserved, never replaced (IKE-Network/ike-issues#964).
*/
static void updateSubprojectDependencies(Path manifestPath, String subprojectName,
List<DerivedDep> derivedDeps) throws IOException {
String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
String updated = DependsOnMerge.merge(yaml, subprojectName,
derivedDeps == null ? List.of() : derivedDeps).yaml();
if (!updated.equals(yaml)) {
Files.writeString(manifestPath, updated, StandardCharsets.UTF_8);
}
}
// ── POM generation ───────────────────────────────────────────
/**
* Add the subproject to the reactor POM's top-level
* {@code <subprojects>} block via the OpenRewrite-LST {@link ReactorPom}
* editor (no regex on POMs). Idempotent — a no-op when the entry is
* already declared. Any residual legacy {@code with-<subproject>}
* profile is dropped so {@code ws:add} never re-creates the pattern the
* #696 reconciler retires.
*
* @param pomPath the reactor POM path
* @throws IOException if the POM cannot be read or written
*/
void addSubprojectToPom(Path pomPath) throws IOException {
String pom = Files.readString(pomPath, StandardCharsets.UTF_8);
String updated = pom;
if (!ReactorPom.listSubprojects(updated).contains(subproject)) {
List<String> names = new ArrayList<>(
ReactorPom.listSubprojects(updated));
names.add(subproject);
updated = ReactorPom.setSubprojects(updated, names);
}
// Retire any legacy file-activated profile for this subproject.
updated = ReactorPom.removeProfile(updated, "with-" + subproject);
if (updated.equals(pom)) {
getLog().info(" Subproject " + subproject
+ " already in <subprojects>");
return;
}
Files.writeString(pomPath, updated, StandardCharsets.UTF_8);
}
// ── Branch resolution ────────────────────────────────────────
/**
* Resolve the branch that the new subproject should land on.
*
* <p>Precedence (ike-issues#286):
* <ol>
* <li>The workspace repo's current git branch is authoritative.
* If {@code -Dbranch=} was given and disagrees, fail — that's
* a request for heterogeneous branch state, which is not
* supported.</li>
* <li>If the workspace dir is not a git repo (no {@code .git}),
* use {@code -Dbranch=} if provided.</li>
* <li>Otherwise, fall back to the manifest's
* {@code defaults.branch}.</li>
* </ol>
*
* @param wsDir the workspace root directory
* @param manifestPath path to workspace.yaml
* @return the resolved branch name; never null or blank
* @throws MojoException if {@code -Dbranch} disagrees with the
* workspace repo's current branch, or if no
* branch can be resolved at all
*/
private String resolveBranch(Path wsDir, Path manifestPath) throws MojoException {
String requested = (branch != null && !branch.isBlank()) ? branch : null;
String wsBranch = workspaceHeadBranch(wsDir);
if (wsBranch != null) {
if (requested != null && !requested.equals(wsBranch)) {
throw new MojoException(
"Requested branch '" + requested + "' disagrees with the "
+ "workspace repo's current branch '" + wsBranch + "'. "
+ "All subprojects in a workspace must track the same "
+ "branch (ike-issues#286). Either run on the matching "
+ "branch in the workspace repo, or omit -Dbranch= to "
+ "use the workspace's current branch.");
}
return wsBranch;
}
if (requested != null) return requested;
// No workspace git state, no -Dbranch — fall back to defaults.branch.
try {
String def = ManifestReader.read(manifestPath).defaults().branch();
if (def != null && !def.isBlank()) return def;
} catch (ManifestException e) {
// fall through to error below
}
throw new MojoException(
"Cannot resolve a branch for the new subproject: workspace repo "
+ "has no git state, no -Dbranch was given, and "
+ "defaults.branch is unset in workspace.yaml.");
}
/**
* Read the workspace repo's current branch, or null if the workspace
* directory is not a git repository.
*/
private static String workspaceHeadBranch(Path wsDir) {
if (!Files.isDirectory(wsDir.resolve(".git"))) return null;
try {
String b = VcsOperations.currentBranch(wsDir.toFile());
return (b == null || b.isBlank()) ? null : b;
} catch (MojoException e) {
return null;
}
}
// ── Clone ────────────────────────────────────────────────────
/**
* Clone the subproject onto the resolved workspace branch.
*
* <p>If the remote already has the branch, clones with {@code -b}.
* If the branch is absent on the remote, clones the remote's default
* branch and creates the workspace branch locally — mirroring what
* {@code ws:feature-start-publish} would have done if this subproject
* had been a workspace member at the time. The new branch is left
* unpushed; a subsequent {@code ws:push} promotes it to origin
* alongside the workspace.yaml change.
*/
private void cloneSubproject(Path wsDir) throws MojoException {
cloneSubprojectFull(wsDir, repo, subproject, branch, getLog());
}
/**
* Clone a subproject with a <em>full</em> clone — never {@code --depth}
* or {@code --single-branch} — and check out the requested branch.
*
* <p>#947: a shallow or refspec-narrowed clone blinds every downstream
* {@code origin/<target>} comparison (behind/ahead, merge ancestry,
* finish-draft classification reads "no local main; would create from
* origin/main" against repos that have in fact diverged, and shallow
* history makes merge-base report unrelated histories). Branch
* selection governs only the checkout, never the refspec or depth.
*
* @param wsDir the workspace root directory to clone under
* @param repo the repository URL
* @param subproject the target directory / subproject name
* @param branch the branch to check out after cloning
* @param log Maven logger
* @throws MojoException if the clone or checkout fails
*/
static void cloneSubprojectFull(Path wsDir, String repo, String subproject,
String branch,
org.apache.maven.api.plugin.Log log)
throws MojoException {
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "clone", repo, subproject);
File dir = wsDir.resolve(subproject).toFile();
String defaultBranch = VcsOperations.currentBranch(dir);
if (defaultBranch.equals(branch)) {
return;
}
if (remoteHasBranch(wsDir, repo, branch)) {
// Plain checkout DWIMs a local tracking branch from
// origin/<branch> — the full refspec guarantees it resolves.
ReleaseSupport.exec(dir, log, "git", "checkout", branch);
} else {
log.info(" Remote has no branch '" + branch
+ "' — creating it locally from the remote's default.");
ReleaseSupport.exec(dir, log, "git", "checkout", "-b", branch);
}
}
/**
* Aligns a pre-existing subproject checkout to the resolved workspace branch —
* the existing-directory counterpart of {@link #cloneSubproject(Path)}'s branch
* handling (IKE-Network/ike-issues#902). No-op when the checkout is already on
* the branch. Otherwise, in order: check out the existing local branch; fetch
* and track it when only the remote has it; create it from the current HEAD
* when nobody has it — exactly what the clone path would have produced. A
* modified worktree refuses loudly rather than switching branches under
* uncommitted changes: heterogeneous branch state across a workspace is not a
* supported configuration, and neither is silently carrying it forward.
*
* <p>Every path that changes (or confirms) the branch also records the
* alignment in {@code .ike/vcs-state} via {@link #recordAlignment(Path)} —
* without that record, the VCS-bridge sync in the very next {@code ws:*}
* goal restores the state-file branch and silently undoes the alignment
* (IKE-Network/ike-issues#903).
*
* @param subprojectDir the pre-existing subproject checkout
* @throws MojoException if the worktree has uncommitted changes, or a git step fails
*/
private void alignExistingCheckout(Path subprojectDir) throws MojoException {
String current = gitCapture(subprojectDir, "git", "rev-parse", "--abbrev-ref", "HEAD");
if (branch.equals(current)) {
getLog().info(" Existing checkout already on '" + branch + "'.");
// Even with the checkout already correct, a stale state file
// naming a different branch would make the next goal's bridge
// sync switch the checkout AWAY from the workspace branch.
// Refresh it; a clean no-op stays a no-op.
VcsOperations.refreshStaleBranchState(subprojectDir.toFile(), getLog());
return;
}
String dirty = gitCapture(subprojectDir, "git", "status", "--porcelain");
if (!dirty.isEmpty()) {
throw new MojoException("Pre-existing checkout of '" + subproject + "' is on '"
+ current + "' but the workspace branch is '" + branch
+ "', and the worktree has uncommitted changes — commit or stash them,"
+ " then re-run ws:add. Heterogeneous branch state across a workspace"
+ " is not a supported configuration (IKE-Network/ike-issues#902).");
}
boolean localHasBranch = gitSucceeds(subprojectDir,
"git", "rev-parse", "--verify", "--quiet", "refs/heads/" + branch);
if (localHasBranch) {
getLog().info(" Existing checkout on '" + current
+ "' — switching to existing local branch '" + branch + "'.");
ReleaseSupport.exec(subprojectDir.toFile(), getLog(), "git", "checkout", branch);
recordAlignment(subprojectDir);
return;
}
if (gitSucceeds(subprojectDir, "git", "fetch", "origin",
"+refs/heads/" + branch + ":refs/remotes/origin/" + branch)) {
getLog().info(" Existing checkout on '" + current
+ "' — tracking remote branch '" + branch + "'.");
ReleaseSupport.exec(subprojectDir.toFile(), getLog(),
"git", "checkout", "-b", branch, "origin/" + branch);
recordAlignment(subprojectDir);
return;
}
getLog().info(" Existing checkout on '" + current + "' and no branch '" + branch
+ "' anywhere — creating it from the current HEAD.");
ReleaseSupport.exec(subprojectDir.toFile(), getLog(), "git", "checkout", "-b", branch);
recordAlignment(subprojectDir);
}
/**
* Records a completed branch alignment as a VCS action in
* {@code .ike/vcs-state} via {@link VcsOperations#recordSwitch}, so the
* VCS-bridge sync in subsequent goals carries the aligned branch forward
* instead of restoring the branch the state file remembered
* (IKE-Network/ike-issues#903).
*
* @param subprojectDir the aligned subproject checkout
* @throws MojoException if writing the state file fails
*/
private void recordAlignment(Path subprojectDir) throws MojoException {
VcsOperations.recordSwitch(subprojectDir.toFile(), getLog());
}
/**
* Runs a git command in {@code dir} and returns its trimmed stdout, or an empty
* string on any failure — callers treat absence of output as absence of the
* probed state.
*/
private static String gitCapture(Path dir, String... cmd) {
try {
Process proc = new ProcessBuilder(cmd)
.directory(dir.toFile())
.redirectErrorStream(false)
.start();
String stdout = new String(proc.getInputStream().readAllBytes(),
StandardCharsets.UTF_8).trim();
int exit = proc.waitFor();
return exit == 0 ? stdout : "";
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return "";
}
}
/**
* Runs a git command in {@code dir} for its exit status alone.
*/
private static boolean gitSucceeds(Path dir, String... cmd) {
try {
Process proc = new ProcessBuilder(cmd)
.directory(dir.toFile())
.redirectErrorStream(true)
.start();
proc.getInputStream().readAllBytes();
return proc.waitFor() == 0;
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return false;
}
}
/**
* Probe whether {@code refs/heads/<branch>} exists on the given remote
* URL via {@code git ls-remote --heads}. Returns false on any failure
* (offline, auth, unknown repo) — the caller will surface the real
* error from the subsequent {@code git clone} attempt.
*/
private static boolean remoteHasBranch(Path wsDir, String repoUrl, String branch) {
try {
ProcessBuilder pb = new ProcessBuilder(
"git", "ls-remote", "--heads", repoUrl, branch)
.directory(wsDir.toFile())
.redirectErrorStream(false);
Process proc = pb.start();
String stdout = new String(proc.getInputStream().readAllBytes(),
StandardCharsets.UTF_8).trim();
int exit = proc.waitFor();
return exit == 0 && !stdout.isEmpty();
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return false;
}
}
// ── Acyclicity gate (#962) ─────────────────────────────────
/**
* Fail the goal when the manifest just written carries a repo-level
* {@code depends-on} cycle, restoring {@code workspace.yaml} and the
* reactor POM to their pre-add contents first
* (IKE-Network/ike-issues#962).
*
* <p>Contraction-induced cycles arise when a reactor leaf of one
* repo bundles a sibling repo's artifact while that repo builds
* against other modules of the first — both real POM edges, cyclic
* only at repo granularity. Committing such a manifest would block
* every graph-consuming {@code ws:} goal, so {@code ws:add} aborts
* with the contributing module-level edges instead. Bundle-time
* edges can be modeled with {@code relationship: bundle}
* (IKE-Network/ike-issues#963), which the ordering graph ignores.
*
* @param log plugin log
* @param wsDir workspace root directory
* @param manifestPath path to workspace.yaml (already rewritten)
* @param pomPath path to the reactor POM (already rewritten)
* @param manifestBefore pre-add workspace.yaml content to restore
* @param pomBefore pre-add reactor POM content to restore
* @throws MojoException when the written graph contains a cycle
*/
static void failOnDependsOnCycle(Log log, Path wsDir, Path manifestPath,
Path pomPath, String manifestBefore,
String pomBefore) throws MojoException {
List<String> cycle;
Map<String, Map<String, List<PomRef>>> edgeSources =
new LinkedHashMap<>();
try {
Manifest manifest = ManifestReader.read(manifestPath);
cycle = DependsOnCycleGate.findCycle(
DependsOnCycleGate.orderingGraph(manifest));
if (cycle.isEmpty()) return;
// Cold path: attribute each cycle edge to the module POMs
// that create it, where the member is cloned.
for (int i = 0; i < cycle.size() - 1; i++) {
String from = cycle.get(i);
Path fromDir = wsDir.resolve(from);
if (!Files.exists(fromDir.resolve("pom.xml"))) continue;
edgeSources.put(from, deriveDependenciesDetailed(
wsDir, manifestPath, fromDir, from).producerSources());
}
} catch (IOException | ManifestException e) {
log.warn(" Could not verify depends-on acyclicity: "
+ e.getMessage());
return;
}
try {
Files.writeString(manifestPath, manifestBefore,
StandardCharsets.UTF_8);
Files.writeString(pomPath, pomBefore, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn(" Could not restore pre-add workspace files: "
+ e.getMessage());
}
throw new MojoException(DependsOnCycleGate.diagnostic(
wsDir, cycle, edgeSources));
}
// ── POM-based dependency derivation ────────────────────────
/**
* Derive dependencies by scanning the new subproject's POMs for
* referenced {@code groupId:artifactId} pairs and matching them
* against the published artifact sets of already-registered
* workspace subprojects.
*
* <p>This is artifact-level matching, not groupId-level — it
* correctly handles subprojects that share a groupId (e.g.,
* tinkar-core and tinkar-composer both use {@code dev.ikm.tinkar}).
*/
static List<DerivedDep> deriveDependencies(Path wsDir, Path manifestPath,
Path subprojectDir, String subprojectName)
throws IOException {
List<DerivedDep> deps = deriveDependenciesDetailed(
wsDir, manifestPath, subprojectDir, subprojectName).deps();
return deps.isEmpty() ? null : deps;
}
/**
* {@link #deriveDependencies} with source attribution: alongside the
* derived entries, records for each producing subproject the module
* POMs (and coordinates) whose references create the repo-level
* edge. The attribution feeds the acyclicity gate's diagnostic so a
* contraction-induced cycle can be reported as module edges with
* file locations rather than repo names alone
* (IKE-Network/ike-issues#962).
*
* @return the derivation; {@code deps} is empty (never null) when
* nothing was derived
*/
static Derivation deriveDependenciesDetailed(Path wsDir, Path manifestPath,
Path subprojectDir, String subprojectName)
throws IOException {
// Collect all groupId:artifactId pairs referenced by this
// subproject, remembering which module POM referenced each.
Map<String, Set<Path>> referenceSources = new LinkedHashMap<>();
Set<String> referencedArtifacts = new LinkedHashSet<>();
Set<String> bundleReferenced = new LinkedHashSet<>();
scanPomForArtifacts(subprojectDir.resolve("pom.xml"),
referencedArtifacts, bundleReferenced, referenceSources);
// Build-strength wins: a true dependency declared by any module
// outranks plugin staging of the same artifact elsewhere in the
// repo (ike-issues#965).
bundleReferenced.removeAll(referencedArtifacts);
if (referencedArtifacts.isEmpty() && bundleReferenced.isEmpty()) {
return new Derivation(List.of(), Map.of());
}
// Read the new subproject's <properties> for version-property detection
Map<String, String> newSubProperties;
try {
DocumentBuilder db = DBF.newDocumentBuilder();
Document doc = db.parse(subprojectDir.resolve("pom.xml").toFile());
newSubProperties = readProperties(doc.getDocumentElement());
} catch (Exception e) {
newSubProperties = Map.of();
}
Manifest manifest = ManifestReader.read(manifestPath);
SubprojectResolver resolver = SubprojectResolver.scan(wsDir, manifest);
// Map each referenced coordinate to the subproject that PRODUCES
// it (full groupId+artifactId; external coordinates resolve to
// empty and are skipped). Dedupe by producer, then emit in
// manifest order to keep derived-dependency ordering stable.
Map<String, String> versionPropertyByProducer = new LinkedHashMap<>();
Map<String, String> relationshipByProducer = new LinkedHashMap<>();
Map<String, List<PomRef>> producerSources = new LinkedHashMap<>();
// Build-strength coordinates first so a producer referenced both
// ways classifies as build (putIfAbsent below never downgrades).
Map<String, String> strengthByCoord = new LinkedHashMap<>();
for (String coord : referencedArtifacts) {
strengthByCoord.put(coord, "build");
}
for (String coord : bundleReferenced) {
strengthByCoord.put(coord, "bundle");
}
for (Map.Entry<String, String> ref : strengthByCoord.entrySet()) {
String coord = ref.getKey();
int colon = coord.indexOf(':');
if (colon < 0) continue;
Optional<String> producer = resolver.subprojectForCoordinate(
coord.substring(0, colon), coord.substring(colon + 1));
if (producer.isEmpty()) continue; // external
String producerName = producer.get();
if (producerName.equals(subprojectName)) continue; // never self
if ("build".equals(ref.getValue())) {
relationshipByProducer.put(producerName, "build");
} else {
relationshipByProducer.putIfAbsent(producerName, "bundle");
}
List<PomRef> refs = producerSources.computeIfAbsent(
producerName, k -> new ArrayList<>());
for (Path source : referenceSources.getOrDefault(coord, Set.of())) {
refs.add(new PomRef(coord, source));
}
if (!versionPropertyByProducer.containsKey(producerName)) {
versionPropertyByProducer.put(producerName,
detectVersionProperty(
manifest.subprojects().get(producerName),
newSubProperties));
}
}
List<DerivedDep> matched = new ArrayList<>();
for (String name : manifest.subprojects().keySet()) {
if (relationshipByProducer.containsKey(name)) {
matched.add(new DerivedDep(
name, versionPropertyByProducer.get(name),
relationshipByProducer.get(name)));
}
}
return new Derivation(List.copyOf(matched), producerSources);
}
/**
* Find a property declared in the newly added subproject whose value
* equals the producer subproject's version — the
* {@code version-property} hint recorded on a derived
* {@code depends-on} edge so {@code ws:align} can track the upstream
* version through a {@code ${...}} property. Returns null when the
* producer has no known version or no matching property exists.
*
* @param producer the upstream (depended-on) subproject, or null
* @param newSubProperties the new subproject's {@code <properties>}
* @return the matching property name, or null
*/
private static String detectVersionProperty(Subproject producer,
Map<String, String> newSubProperties) {
if (producer == null) return null;
String upstreamVersion = producer.version();
if (upstreamVersion == null || newSubProperties.isEmpty()) return null;
for (Map.Entry<String, String> prop : newSubProperties.entrySet()) {
if (upstreamVersion.equals(prop.getValue())) {
return prop.getKey();
}
}
return null;
}
/**
* Determine whether a new subproject's declared Maven {@code <parent>}
* is itself a workspace subproject, returning that subproject's name
* (its {@code workspace.yaml} key) for the {@code parent:} field.
*
* <p>Matching requires the parent's <strong>groupId AND
* artifactId</strong> to match a workspace subproject's published
* artifact set. groupId alone is insufficient: an external parent
* such as {@code network.ike.platform:ike-parent} that merely shares
* a groupId with a sibling subproject must be treated as having no
* workspace parent, so no bogus {@code parent:} is recorded
* (ike-issues#565). This mirrors the artifact-level matching used by
* {@link #deriveDependencies} and the GA-matching rule the
* {@code ws:align} reconcilers apply (issue #241).
*
* @param wsDir the workspace root directory
* @param manifestPath path to {@code workspace.yaml}
* @param parentInfo the new subproject's declared parent, or null
* when the POM has no {@code <parent>} block
* @return the workspace subproject name whose published artifact set
* contains the parent's {@code groupId:artifactId}, or null
* when the parent is external / not a workspace member
* @throws IOException if a candidate subproject's POM cannot be read
* @throws ManifestException if the manifest cannot be parsed
*/
static String detectWorkspaceParent(Path wsDir, Path manifestPath,
PomParentSupport.ParentInfo parentInfo)
throws IOException, ManifestException {
if (parentInfo == null) {
return null;
}
Manifest manifest = ManifestReader.read(manifestPath);
return SubprojectResolver.scan(wsDir, manifest)
.subprojectForCoordinate(
parentInfo.groupId(), parentInfo.artifactId())
.orElse(null);
}
/**
* Backward resolution: for each existing cloned subproject, check
* whether its POMs reference any artifact published by the newly
* added subproject. Uses artifact-level matching via
* {@link PublishedArtifactSet} to avoid false positives from
* shared groupIds.
*/
private int backfillDependencies(Path wsDir, Path manifestPath,
String newSubproject, Path newSubprojectDir)
throws IOException {
String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
Manifest manifest = ManifestReader.read(manifestPath);
// The new subproject is already registered by the time backfill
// runs, so the shared resolver maps its published coordinates
// back to it (full GA — no false positives from shared groupIds).
SubprojectResolver resolver = SubprojectResolver.scan(wsDir, manifest);
int updated = 0;
for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
String existingName = entry.getKey();
Subproject existing = entry.getValue();
// Skip the newly added subproject itself
if (existingName.equals(newSubproject)) continue;
// Skip if already depends on the new subproject
if (existing.dependsOn() != null
&& existing.dependsOn().stream()
.anyMatch(d -> newSubproject.equals(d.subproject()))) {
continue;
}
// Check if this existing subproject references any artifact
// produced by the new subproject (full GA via the resolver).
Path existingPom = wsDir.resolve(existingName).resolve("pom.xml");
if (!Files.exists(existingPom)) continue;
boolean dependsOnNew = extractReferencedArtifacts(existingPom).stream()
.anyMatch(coord -> resolvesTo(resolver, coord, newSubproject));
if (!dependsOnNew) continue;
yaml = addDependencyEdge(yaml, existingName, newSubproject, null);
updated++;
getLog().info(Ansi.cyan(" → ") + existingName + " depends on " + newSubproject);
}
if (updated > 0) {
Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
}
return updated;
}
/**
* Whether a {@code "groupId:artifactId"} coordinate resolves (full
* GA) to the named subproject via the shared resolver.
*/
private static boolean resolvesTo(SubprojectResolver resolver,
String coord, String subprojectName) {
int colon = coord.indexOf(':');
if (colon < 0) return false;
return resolver.subprojectForCoordinate(
coord.substring(0, colon), coord.substring(colon + 1))
.map(subprojectName::equals).orElse(false);
}
/**
* Add a depends-on edge for an existing subproject in workspace.yaml.
* Converts {@code depends-on: []} to a populated list, or appends
* to an existing list.
*/
static String addDependencyEdge(String yaml, String subprojectName,
String dependsOnName, String versionProperty) {
String versionPropertyLine = (versionProperty != null)
? " version-property: " + versionProperty + "\n" : "";
// Case 1: depends-on: [] — replace with populated entry
String emptyDeps = "(" + subprojectName + ":[\\s\\S]*?)(depends-on:\\s*\\[])";
Pattern emptyPattern = Pattern.compile(emptyDeps);
Matcher emptyMatcher = emptyPattern.matcher(yaml);
if (emptyMatcher.find()) {
String replacement = emptyMatcher.group(1)
+ "depends-on:\n"
+ " - subproject: " + dependsOnName + "\n"
+ " relationship: build\n"
+ versionPropertyLine;
return emptyMatcher.replaceFirst(Matcher.quoteReplacement(replacement));
}
// Case 2: existing depends-on list — append before next subproject
// or section. Find the subproject's depends-on block and add an entry.
String existingDeps = "(" + subprojectName
+ ":[\\s\\S]*?depends-on:\\n)((?:\\s+- subproject:.*\\n(?:\\s+relationship:.*\\n)(?:\\s+version-property:.*\\n)?)*)";
Pattern existingPattern = Pattern.compile(existingDeps);
Matcher existingMatcher = existingPattern.matcher(yaml);
if (existingMatcher.find()) {
String replacement = existingMatcher.group(1)
+ existingMatcher.group(2)
+ " - subproject: " + dependsOnName + "\n"
+ " relationship: build\n"
+ versionPropertyLine;
return existingMatcher.replaceFirst(Matcher.quoteReplacement(replacement));
}
return yaml;
}
/**
* Extract all {@code groupId:artifactId} pairs referenced as
* build dependencies across the entire subproject (root POM +
* all submodules/subprojects).
*
* <p>Uses DOM parsing to correctly read XML structure and
* resolves Maven property references ({@code ${property.name}})
* from the POM's {@code <properties>} section.
*
* <p>Scans {@code <parent>}, {@code <dependencies>}, BOM imports
* inside {@code <dependencyManagement>} (entries with both
* {@code <scope>import</scope>} and {@code <type>pom</type>}), and
* build {@code <plugins>} (including {@code <pluginManagement>}).
* Profile-scoped versions of these are scanned as well, since any
* profile activation makes those dependencies real. Plain
* version-constraint entries inside {@code <dependencyManagement>}
* are excluded — they aren't build edges by themselves. (#239)
*
* @return set of "groupId:artifactId" strings
*/
static Set<String> extractReferencedArtifacts(Path pomFile) throws IOException {
Set<String> artifacts = new LinkedHashSet<>();
scanPomForArtifacts(pomFile, artifacts);
return artifacts;
}
/**
* Recursively scan a POM and its submodules for referenced
* groupId:artifactId pairs using DOM parsing with property
* resolution.
*/
static void scanPomForArtifacts(Path pomFile, Set<String> artifacts)
throws IOException {
scanPomForArtifacts(pomFile, artifacts, null);
}
/**
* As {@link #scanPomForArtifacts(Path, Set)}, additionally recording
* which POM file referenced each coordinate when {@code sources} is
* non-null — the per-module attribution behind the acyclicity gate's
* diagnostic (IKE-Network/ike-issues#962).
*
* <p>Legacy build-strength view: plugin-staged references are
* scanned but discarded, preserving pre-#965 semantics for callers
* that ask "does this POM truly depend on X" (the {@code ws:add}
* reverse-edge check via {@link #extractReferencedArtifacts}).
*
* @param sources coordinate → referencing POM files accumulator,
* or null to skip attribution
*/
static void scanPomForArtifacts(Path pomFile, Set<String> artifacts,
Map<String, Set<Path>> sources)
throws IOException {
scanPomForArtifacts(pomFile, artifacts, new LinkedHashSet<>(),
sources);
}
/**
* Full classified scan: build-strength references (parent,
* dependencies, BOM imports, plugin GAVs) accumulate into
* {@code artifacts}; plugin-staged references (a plugin's own
* {@code <dependencies>} entries and descendant
* {@code <artifactItem>} elements) accumulate into
* {@code bundleArtifacts} and derive as {@code relationship: bundle}
* (ike-issues#965). Source attribution covers both strengths.
*
* @param bundleArtifacts accumulator for plugin-staged coordinates
* @param sources coordinate → referencing POM files
* accumulator, or null to skip attribution
*/
static void scanPomForArtifacts(Path pomFile, Set<String> artifacts,
Set<String> bundleArtifacts,
Map<String, Set<Path>> sources)
throws IOException {
if (!Files.exists(pomFile)) return;
Document doc;
try {
DocumentBuilder db = DBF.newDocumentBuilder();
doc = db.parse(pomFile.toFile());
} catch (Exception e) {
// If we can't parse, skip this POM
return;
}
Element project = doc.getDocumentElement();
// Read <properties> for ${...} resolution
Map<String, String> properties = readProperties(project);
// Scan the project root for parent/deps/BOMs/plugins, keeping
// this file's finds separate so they can be attributed to it.
Set<String> found = new LinkedHashSet<>();
Set<String> foundStaged = new LinkedHashSet<>();
collectArtifactsFromContainer(project, properties, found, foundStaged);
// Scan each profile body — a profile's deps/plugins become real
// when it activates, so they're legitimate build edges.
Element profilesEl = firstChild(project, "profiles");
if (profilesEl != null) {
for (Element profile : children(profilesEl, "profile")) {
collectArtifactsFromContainer(
profile, properties, found, foundStaged);
}
}
artifacts.addAll(found);
bundleArtifacts.addAll(foundStaged);
if (sources != null) {
for (String coord : found) {
sources.computeIfAbsent(coord, k -> new LinkedHashSet<>())
.add(pomFile);
}
for (String coord : foundStaged) {
sources.computeIfAbsent(coord, k -> new LinkedHashSet<>())
.add(pomFile);
}
}
// Recurse into subprojects (Maven 4.1.0) and modules (Maven 4.0.0)
Path pomDir = pomFile.getParent();
Element subprojects = firstChild(project, "subprojects");
if (subprojects != null) {
for (Element sub : children(subprojects, "subproject")) {
String name = sub.getTextContent().trim();
scanPomForArtifacts(pomDir.resolve(name).resolve("pom.xml"),
artifacts, bundleArtifacts, sources);
}
}
Element modules = firstChild(project, "modules");
if (modules != null) {
for (Element mod : children(modules, "module")) {
String name = mod.getTextContent().trim();
scanPomForArtifacts(pomDir.resolve(name).resolve("pom.xml"),
artifacts, bundleArtifacts, sources);
}
}
}
/**
* Pull groupId:artifactId pairs out of a container element — either
* a {@code <project>} root or a {@code <profile>} body. Reads:
* <ul>
* <li>{@code <parent>}</li>
* <li>{@code <dependencies><dependency>}</li>
* <li>{@code <dependencyManagement><dependencies><dependency>} —
* BOM imports only ({@code <scope>import</scope>} with
* {@code <type>pom</type>})</li>
* <li>{@code <build><plugins><plugin>}</li>
* <li>{@code <build><pluginManagement><plugins><plugin>}</li>
* </ul>
*
* <p>Plugin-staged references — entries of a plugin's own
* {@code <dependencies>} block and descendant {@code <artifactItem>}
* elements — accumulate separately into {@code stagedArtifacts}:
* they are package-time provisioning references, resolved from a
* repository rather than ordered in the workspace build, and derive
* as {@code relationship: bundle} (ike-issues#965, #963).
*
* @param container the {@code <project>} or {@code <profile>} element
* @param properties resolved {@code <properties>} for {@code ${...}} references
* @param artifacts accumulator for build-strength "groupId:artifactId" strings
* @param stagedArtifacts accumulator for plugin-staged "groupId:artifactId" strings
*/
static void collectArtifactsFromContainer(
Element container,
Map<String, String> properties,
Set<String> artifacts,
Set<String> stagedArtifacts) {
// <parent> — present on project root; profiles don't allow it.
addArtifactCoords(firstChild(container, "parent"), properties, artifacts);
// Direct build dependencies.
Element depsEl = firstChild(container, "dependencies");
if (depsEl != null) {
for (Element dep : children(depsEl, "dependency")) {
addArtifactCoords(dep, properties, artifacts);
}
}
// BOM imports — entries declared with scope=import + type=pom.
// Plain version-constraint entries are skipped (they don't bind
// anything until something else declares the dep).
Element depMgmt = firstChild(container, "dependencyManagement");
if (depMgmt != null) {
Element dmDeps = firstChild(depMgmt, "dependencies");
if (dmDeps != null) {
for (Element dep : children(dmDeps, "dependency")) {
String scope = resolve(childText(dep, "scope"), properties);
String type = resolve(childText(dep, "type"), properties);
if ("import".equals(scope) && "pom".equals(type)) {
addArtifactCoords(dep, properties, artifacts);
}
}
}
}
// Build plugins — both top-level <plugins> and <pluginManagement>.
// Plugin GAVs are build-strength (a workspace-produced Maven
// plugin must exist before its consumer builds); the plugin's
// own <dependencies> and <artifactItem>s are staged (#965).
Element build = firstChild(container, "build");
if (build != null) {
Element pluginsEl = firstChild(build, "plugins");
addPluginCoords(pluginsEl, properties, artifacts);
addPluginStagedCoords(pluginsEl, properties, stagedArtifacts);
Element pluginMgmt = firstChild(build, "pluginManagement");
if (pluginMgmt != null) {
Element managedEl = firstChild(pluginMgmt, "plugins");
addPluginCoords(managedEl, properties, artifacts);
addPluginStagedCoords(
managedEl, properties, stagedArtifacts);
}
}
}
/**
* Add every {@code <plugin>} child of the given {@code <plugins>}
* element to the artifact accumulator. No-op if {@code pluginsEl}
* is null.
*
* @param pluginsEl the {@code <plugins>} element, or null
* @param properties resolved {@code <properties>} for property substitution
* @param artifacts accumulator
*/
private static void addPluginCoords(Element pluginsEl,
Map<String, String> properties,
Set<String> artifacts) {
if (pluginsEl == null) return;
for (Element plugin : children(pluginsEl, "plugin")) {
addArtifactCoords(plugin, properties, artifacts);
}
}
/**
* Harvest plugin-staged references from every {@code <plugin>}
* child: entries of the plugin's own {@code <dependencies>} block
* (the reactor-ordering-only idiom) and any descendant
* {@code <artifactItem>} (the {@code maven-dependency-plugin}
* copy/unpack idiom, wherever it nests under configuration or
* executions). No-op if {@code pluginsEl} is null.
*
* @param pluginsEl the {@code <plugins>} element, or null
* @param properties resolved {@code <properties>} for property
* substitution
* @param stagedArtifacts accumulator for staged coordinates
*/
private static void addPluginStagedCoords(Element pluginsEl,
Map<String, String> properties,
Set<String> stagedArtifacts) {
if (pluginsEl == null) return;
for (Element plugin : children(pluginsEl, "plugin")) {
Element depsEl = firstChild(plugin, "dependencies");
if (depsEl != null) {
for (Element dep : children(depsEl, "dependency")) {
addArtifactCoords(dep, properties, stagedArtifacts);
}
}
org.w3c.dom.NodeList items =
plugin.getElementsByTagName("artifactItem");
for (int i = 0; i < items.getLength(); i++) {
addArtifactCoords((Element) items.item(i), properties,
stagedArtifacts);
}
}
}
/**
* Resolve an element's {@code <groupId>}/{@code <artifactId>} pair
* (with property substitution) and add it to the accumulator. No-op
* if {@code el} is null or either coord is missing.
*
* @param el the element with groupId/artifactId children
* @param properties resolved {@code <properties>} for property substitution
* @param artifacts accumulator
*/
private static void addArtifactCoords(Element el,
Map<String, String> properties,
Set<String> artifacts) {
if (el == null) return;
String gid = resolve(childText(el, "groupId"), properties);
String aid = resolve(childText(el, "artifactId"), properties);
if (gid != null && aid != null) {
artifacts.add(gid + ":" + aid);
}
}
// ── DOM helpers ─────────────────────────────────────────────
private static final javax.xml.parsers.DocumentBuilderFactory DBF;
static {
DBF = javax.xml.parsers.DocumentBuilderFactory.newInstance();
try {
DBF.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
DBF.setFeature("http://xml.org/sax/features/external-general-entities", false);
DBF.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
} catch (javax.xml.parsers.ParserConfigurationException e) {
// Non-fatal
}
}
/**
* Read {@code <properties>} from a POM's project element into
* a map for {@code ${...}} resolution.
*/
private static Map<String, String> readProperties(Element project) {
Map<String, String> props = new LinkedHashMap<>();
Element propsEl = firstChild(project, "properties");
if (propsEl != null) {
org.w3c.dom.NodeList children = propsEl.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
org.w3c.dom.Node node = children.item(i);
if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
String value = node.getTextContent().trim();
if (!value.isEmpty()) {
props.put(node.getNodeName(), value);
}
}
}
}
return props;
}
/**
* Resolve {@code ${property.name}} references in a string using
* the given property map. Returns the input unchanged if no
* property reference is present or if the property is not found.
*/
private static String resolve(String value, Map<String, String> properties) {
if (value == null || !value.contains("${")) return value;
for (Map.Entry<String, String> entry : properties.entrySet()) {
value = value.replace("${" + entry.getKey() + "}", entry.getValue());
}
// If still contains unresolved references, return as-is
return value;
}
/**
* Get the text content of a direct child element, or null.
*/
private static String childText(Element parent, String tagName) {
Element child = firstChild(parent, tagName);
if (child == null) return null;
String text = child.getTextContent().trim();
return text.isEmpty() ? null : text;
}
/**
* Get the first direct child element with the given tag name.
*/
private static Element firstChild(Element parent, String tagName) {
org.w3c.dom.NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
org.w3c.dom.Node node = children.item(i);
if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE
&& tagName.equals(node.getNodeName())) {
return (Element) node;
}
}
return null;
}
/**
* Get all direct child elements with the given tag name.
*/
private static List<Element> children(Element parent, String tagName) {
List<Element> result = new ArrayList<>();
org.w3c.dom.NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
org.w3c.dom.Node node = children.item(i);
if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE
&& tagName.equals(node.getNodeName())) {
result.add((Element) node);
}
}
return result;
}
// ── Version alignment ───────────────────────────────────────
/**
* Align dependency versions in the newly added subproject's POMs
* to match the workspace reactor's versions. For each workspace
* subproject that this subproject depends on, literal version
* declarations are updated in place; property-referenced versions
* keep their {@code ${...}} indirection, with only stale property
* <em>values</em> updated where declared (IKE-Network/ike-issues#826).
*
* @return the number of POM files modified
*/
private int alignVersions(Path wsDir, Path subprojectDir,
String subprojectName, Manifest manifest)
throws IOException {
Map<String, String> artifactVersions =
collectWorkspaceArtifactVersions(wsDir, subprojectName, manifest);
if (artifactVersions.isEmpty()) return 0;
return alignSubprojectPoms(subprojectDir, artifactVersions, getLog());
}
/**
* Build the {@code groupId:artifactId} → version map for all
* workspace subprojects other than the one being added.
*
* <p>The version comes from each member's checked-out root POM, not
* from manifest metadata: on a feature branch the working tree
* carries the branch-qualified version (e.g.
* {@code 1.127.2-chronology-builder-SNAPSHOT}) while
* {@code workspace.yaml} may still record the main-line version —
* aligning to the latter silently links the added subproject
* against the wrong {@code .m2} jar (IKE-Network/ike-issues#826).
* The manifest version is used only when the POM cannot be read.
*
* @param wsDir the workspace root directory
* @param subprojectName the subproject being added (excluded)
* @param manifest the parsed workspace manifest
* @return map of produced {@code groupId:artifactId} coordinates to
* the producing member's actual reactor version
* @throws IOException if a member's published artifact set cannot
* be scanned
*/
static Map<String, String> collectWorkspaceArtifactVersions(
Path wsDir, String subprojectName, Manifest manifest)
throws IOException {
Map<String, String> artifactVersions = new LinkedHashMap<>();
for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
if (entry.getKey().equals(subprojectName)) continue;
Path subDir = wsDir.resolve(entry.getKey());
Path memberPom = subDir.resolve("pom.xml");
if (!Files.exists(memberPom)) continue;
String memberVersion = null;
try {
memberVersion = ReleaseSupport.readPomVersion(memberPom.toFile());
} catch (MojoException e) {
// Unreadable POM — fall back to the manifest version below.
}
if (memberVersion == null || memberVersion.isBlank()) {
memberVersion = entry.getValue().version();
}
if (memberVersion == null || memberVersion.isBlank()) continue;
Set<PublishedArtifactSet.Artifact> published =
PublishedArtifactSet.scan(subDir);
for (PublishedArtifactSet.Artifact artifact : published) {
artifactVersions.put(
artifact.groupId() + ":" + artifact.artifactId(),
memberVersion);
}
}
return artifactVersions;
}
/**
* Walk every POM in the added subproject and align workspace
* dependency versions via {@link DependencyVersionAligner} (LST
* edits — no regex on POMs). Literal versions are rewritten in
* place; a dependency version held behind a {@code ${...}} property
* is never inlined — instead, a stale property value is updated in
* every POM that declares it, preserving the indirection
* (IKE-Network/ike-issues#826).
*
* @param subprojectDir the added subproject's directory
* @param artifactVersions {@code groupId:artifactId} → reactor
* version for workspace-produced artifacts
* @param log sink for per-file alignment messages
* @return the number of POM files modified
* @throws IOException if a POM cannot be read or written
*/
static int alignSubprojectPoms(Path subprojectDir,
Map<String, String> artifactVersions,
org.apache.maven.api.plugin.Log log)
throws IOException {
List<Path> pomFiles = findAllPomFiles(subprojectDir);
Map<String, String> rootProperties = Map.of();
Path rootPom = subprojectDir.resolve("pom.xml");
if (Files.exists(rootPom)) {
rootProperties = PomRewriter.listProperties(
Files.readString(rootPom, StandardCharsets.UTF_8));
}
Set<Path> modified = new LinkedHashSet<>();
Map<String, String> propertyUpdates = new LinkedHashMap<>();
for (Path pomFile : pomFiles) {
String original = Files.readString(pomFile, StandardCharsets.UTF_8);
// Module properties override the root's, mirroring Maven's
// inheritance for the ${...} references this module declares.
Map<String, String> effective = new LinkedHashMap<>(rootProperties);
effective.putAll(PomRewriter.listProperties(original));
DependencyVersionAligner.Result result =
DependencyVersionAligner.align(
original, artifactVersions, effective);
if (!result.pom().equals(original)) {
Files.writeString(pomFile, result.pom(), StandardCharsets.UTF_8);
modified.add(pomFile);
log.info(" Version alignment: "
+ relativeToWorkspace(subprojectDir, pomFile));
}
result.propertyUpdates().forEach(propertyUpdates::putIfAbsent);
}
// Apply stale-property updates wherever the property is declared
// (usually the subproject root). The ${...} references themselves
// were left untouched above.
for (Map.Entry<String, String> update : propertyUpdates.entrySet()) {
for (Path pomFile : pomFiles) {
String content = Files.readString(pomFile, StandardCharsets.UTF_8);
String updated = PomRewriter.updateProperty(
content, update.getKey(), update.getValue());
if (!updated.equals(content)) {
Files.writeString(pomFile, updated, StandardCharsets.UTF_8);
modified.add(pomFile);
log.info(" Version alignment (property "
+ update.getKey() + "): "
+ relativeToWorkspace(subprojectDir, pomFile));
}
}
}
return modified.size();
}
/**
* Render a POM path relative to the workspace root (the added
* subproject's parent directory) for log messages.
*
* @param subprojectDir the added subproject's directory
* @param pomFile the POM being reported
* @return the workspace-relative path, or the subproject-relative
* path when the subproject has no parent directory
*/
private static Path relativeToWorkspace(Path subprojectDir, Path pomFile) {
Path base = (subprojectDir.getParent() != null)
? subprojectDir.getParent() : subprojectDir;
return base.relativize(pomFile);
}
/**
* Find all pom.xml files in a subproject directory (root + submodules).
*/
private static List<Path> findAllPomFiles(Path subprojectDir) throws IOException {
try (Stream<Path> stream = Files.walk(subprojectDir)) {
return stream
.filter(p -> p.getFileName().toString().equals("pom.xml"))
.filter(p -> !p.toString().contains("/target/"))
.toList();
}
}
/**
* Derive the Maven groupId from the subproject's root POM.
* Strips the parent block first; if no groupId is declared
* outside parent, falls back to the parent's groupId.
*/
private String deriveGroupId(Path subprojectDir) {
Path pomFile = subprojectDir.resolve("pom.xml");
if (!Files.exists(pomFile)) return null;
try {
String content = Files.readString(pomFile, StandardCharsets.UTF_8);
// Try groupId outside parent block first
String stripped = PARENT_BLOCK.matcher(content).replaceFirst("");
Matcher gm = GROUP_ID_PATTERN.matcher(stripped);
if (gm.find()) return gm.group(1).trim();
// Fall back to parent groupId
Matcher parentBlock = PARENT_BLOCK.matcher(content);
if (parentBlock.find()) {
gm = GROUP_ID_PATTERN.matcher(parentBlock.group());
if (gm.find()) return gm.group(1).trim();
}
} catch (IOException e) {
// Non-fatal — groupId will be null in manifest
}
return null;
}
private static final Pattern PARENT_BLOCK =
Pattern.compile("(?s)<parent>.*?</parent>");
private static final Pattern GROUP_ID_PATTERN =
Pattern.compile("<groupId>([^<]+)</groupId>");
// ── Helpers ──────────────────────────────────────────────────
/**
* Derive a subproject name from a git URL.
* {@code https://github.com/ikmdev/tinkar-core.git} → {@code tinkar-core}
*/
static String deriveSubprojectName(String repoUrl) {
String name = repoUrl;
// Strip trailing .git
if (name.endsWith(".git")) {
name = name.substring(0, name.length() - 4);
}
// Strip trailing slash
if (name.endsWith("/")) {
name = name.substring(0, name.length() - 1);
}
// Take last path segment
int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) {
name = name.substring(lastSlash + 1);
}
return name;
}
private String readWorkspaceName(Path wsDir) {
try {
return ReleaseSupport.readPomArtifactId(wsDir.resolve("pom.xml").toFile());
} catch (MojoException e) {
return "Workspace";
}
}
/**
* Apply ike-issues#324 parent-coherence rules to a freshly-added
* subproject. Warning-only at add time — the matching check in
* {@code ws:scaffold-draft} (which folds verify per #393) is the
* authoritative gate.
*
* @param wsDir workspace root directory
* @param subprojectDir subproject's checked-out directory
* @param subprojectName subproject name (workspace.yaml key)
* @param parentInfo subproject's declared parent
*/
private void checkParentCoherenceAtAdd(Path wsDir,
Path subprojectDir,
String subprojectName,
PomParentSupport.ParentInfo parentInfo) {
PomParentSupport.ParentInfo wsParent;
try {
wsParent = PomParentSupport.readParent(wsDir.resolve("pom.xml"));
} catch (Exception e) {
// Workspace pom unreadable or no parent — nothing to enforce.
return;
}
if (wsParent == null
|| wsParent.groupId() == null
|| wsParent.artifactId() == null) {
return;
}
// Decision matrix gate: same GA as workspace's parent?
if (!java.util.Objects.equals(wsParent.groupId(), parentInfo.groupId())
|| !java.util.Objects.equals(wsParent.artifactId(),
parentInfo.artifactId())) {
return;
}
String coords = parentInfo.groupId() + ":" + parentInfo.artifactId();
// Rule 2: version coherence
if (!java.util.Objects.equals(wsParent.version(), parentInfo.version())) {
getLog().warn(" WARN: " + subprojectName + " parent "
+ coords + ":" + parentInfo.version()
+ " != workspace " + coords + ":" + wsParent.version()
+ " (#324 coherence violation — fix before "
+ WsGoal.RELEASE_PUBLISH.qualified() + " or expect "
+ WsGoal.SCAFFOLD_DRAFT.qualified()
+ " to flag it)");
return;
}
// Rule 1: cycle prevention
boolean hasEmptyRelativePath;
try {
hasEmptyRelativePath = PomParentSupport.hasEmptyRelativePath(
subprojectDir.resolve("pom.xml"));
} catch (Exception e) {
return;
}
if (!hasEmptyRelativePath) {
getLog().warn(" WARN: " + subprojectName + " parent "
+ coords + ":" + parentInfo.version()
+ " matches workspace parent but is missing empty "
+ "<relativePath/> (#324 cycle prevention — Maven 4 "
+ "will fail with \"parents form a cycle\" once the "
+ "subproject participates in the reactor; add "
+ "<relativePath/> inside the <parent> block)");
}
}
private Path findWorkspaceRoot() throws MojoException {
Path dir = Path.of(System.getProperty("user.dir"));
while (dir != null) {
if (Files.exists(dir.resolve("workspace.yaml"))) {
return dir;
}
dir = dir.getParent();
}
throw new MojoException(
"Cannot find workspace.yaml. Run from within a workspace "
+ "directory or use " + WsGoal.SCAFFOLD_INIT.qualified()
+ " first.");
}
}